Skip to content

Commit

Permalink
feat: allow waiting for max render count instead of timeout
Browse files Browse the repository at this point in the history
  • Loading branch information
egil committed Sep 24, 2024
1 parent 9f81a91 commit 727d382
Show file tree
Hide file tree
Showing 11 changed files with 370 additions and 72 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,34 @@ public static void WaitForState(this IRenderedFragmentBase renderedFragment, Fun
}
}

/// <summary>
/// Wait until the provided <paramref name="statePredicate"/> action returns true,
/// or the <paramref name="maxRenderCount"/> is reached.
///
/// The <paramref name="statePredicate"/> is evaluated initially, and then each time
/// the <paramref name="renderedFragment"/> renders.
/// </summary>
/// <param name="renderedFragment">The render fragment or component to attempt to verify state against.</param>
/// <param name="statePredicate">The predicate to invoke after each render, which must returns <c>true</c> when the desired state has been reached.</param>
/// <param name="maxRenderCount">The number of renders of <paramref name="renderedFragment"/> should wait at most.</param>
/// <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>
/// <remarks>
/// If a debugger is attached the timeout is set to <see cref="Timeout.InfiniteTimeSpan" />, giving the possibility to debug without the timeout triggering.
/// </remarks>
public static void WaitForState(this IRenderedFragmentBase renderedFragment, Func<bool> statePredicate, int maxRenderCount)
{
using var waiter = new WaitForStateHelper(renderedFragment, statePredicate, maxRenderCount);

try
{
waiter.WaitTask.GetAwaiter().GetResult();
}
catch (AggregateException e) when (e.InnerExceptions.Count == 1)
{
ExceptionDispatchInfo.Capture(e.InnerExceptions[0]).Throw();
}
}

/// <summary>
/// Wait until the provided <paramref name="statePredicate"/> action returns true,
/// or the <paramref name="timeout"/> is reached (default is one second).
Expand All @@ -55,6 +83,24 @@ internal static async Task WaitForStateAsync(this IRenderedFragmentBase rendered
await waiter.WaitTask;
}

/// <summary>
/// Wait until the provided <paramref name="statePredicate"/> action returns true,
/// or the <paramref name="maxRenderCount"/> is reached.
///
/// The <paramref name="statePredicate"/> is evaluated initially, and then each time
/// the <paramref name="renderedFragment"/> renders.
/// </summary>
/// <param name="renderedFragment">The render fragment or component to attempt to verify state against.</param>
/// <param name="statePredicate">The predicate to invoke after each render, which must returns <c>true</c> when the desired state has been reached.</param>
/// <param name="maxRenderCount">The number of renders of <paramref name="renderedFragment"/> should wait at most.</param>
/// <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>
internal static async Task WaitForStateAsync(this IRenderedFragmentBase renderedFragment, Func<bool> statePredicate, int maxRenderCount)
{
using var waiter = new WaitForStateHelper(renderedFragment, statePredicate, maxRenderCount);

await waiter.WaitTask;
}

/// <summary>
/// Wait until the provided <paramref name="assertion"/> passes (i.e. does not throw an
/// exception), or the <paramref name="timeout"/> is reached (default is one second).
Expand All @@ -80,6 +126,31 @@ public static void WaitForAssertion(this IRenderedFragmentBase renderedFragment,
}
}

/// <summary>
/// Wait until the provided <paramref name="assertion"/> passes (i.e. does not throw an exception),
/// or the <paramref name="maxRenderCount"/> is reached.
///
/// The <paramref name="assertion"/> is attempted initially, and then each time the <paramref name="renderedFragment"/> renders.
/// </summary>
/// <param name="renderedFragment">The rendered fragment to wait for renders from and assert against.</param>
/// <param name="assertion">The verification or assertion to perform.</param>
/// <param name="maxRenderCount">The number of renders of <paramref name="renderedFragment"/> should wait at most.</param>
/// <exception cref="WaitForFailedException">Thrown if the timeout has been reached. See the inner exception to see the captured assertion exception.</exception>
[AssertionMethod]
public static void WaitForAssertion(this IRenderedFragmentBase renderedFragment, Action assertion, int maxRenderCount)
{
using var waiter = new WaitForAssertionHelper(renderedFragment, assertion, maxRenderCount);

try
{
waiter.WaitTask.GetAwaiter().GetResult();
}
catch (AggregateException e) when (e.InnerExceptions.Count == 1)
{
ExceptionDispatchInfo.Capture(e.InnerExceptions[0]).Throw();
}
}

/// <summary>
/// Wait until the provided <paramref name="assertion"/> passes (i.e. does not throw an
/// exception), or the <paramref name="timeout"/> is reached (default is one second).
Expand All @@ -97,4 +168,21 @@ internal static async Task WaitForAssertionAsync(this IRenderedFragmentBase rend

await waiter.WaitTask;
}

/// <summary>
/// Wait until the provided <paramref name="assertion"/> passes (i.e. does not throw an exception),
/// or the <paramref name="maxRenderCount"/> is reached.
/// The <paramref name="assertion"/> is attempted initially, and then each time the <paramref name="renderedFragment"/> renders.
/// </summary>
/// <param name="renderedFragment">The rendered fragment to wait for renders from and assert against.</param>
/// <param name="assertion">The verification or assertion to perform.</param>
/// <param name="maxRenderCount">The number of renders of <paramref name="renderedFragment"/> should wait at most.</param>
/// <exception cref="WaitForFailedException">Thrown if the timeout has been reached. See the inner exception to see the captured assertion exception.</exception>
[AssertionMethod]
internal static async Task WaitForAssertionAsync(this IRenderedFragmentBase renderedFragment, Action assertion, int maxRenderCount)
{
using var waiter = new WaitForAssertionHelper(renderedFragment, assertion, maxRenderCount);

await waiter.WaitTask;
}
}
29 changes: 29 additions & 0 deletions src/bunit.core/Extensions/WaitForHelpers/WaitForAssertionHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,12 @@ namespace Bunit.Extensions.WaitForHelpers;
/// </summary>
public class WaitForAssertionHelper : WaitForHelper<object?>
{
internal const string RenderCountErrorMessage = "The assertion did not pass before the maximum render count was reached.";
internal const string TimeoutMessage = "The assertion did not pass within the timeout period.";

/// <inheritdoc/>
protected override string? MaxRenderCountErrorMessage => RenderCountErrorMessage;

/// <inheritdoc/>
protected override string? TimeoutErrorMessage => TimeoutMessage;

Expand Down Expand Up @@ -36,4 +40,29 @@ public WaitForAssertionHelper(IRenderedFragmentBase renderedFragment, Action ass
},
timeout)
{ }

/// <summary>
/// Initializes a new instance of the <see cref="WaitForAssertionHelper"/> class,
/// which will until the provided <paramref name="assertion"/> passes (i.e. does not throw an
/// or the <paramref name="maxRenderCount"/> is reached.
///
/// The <paramref name="assertion"/> is attempted initially, and then each time the <paramref name="renderedFragment"/> renders.
/// </summary>
/// <param name="renderedFragment">The rendered fragment to wait for renders from and assert against.</param>
/// <param name="assertion">The verification or assertion to perform.</param>
/// <param name="maxRenderCount">The number of renders of <paramref name="renderedFragment"/> should wait at most.</param>
/// <remarks>
/// If a debugger is attached the timeout is set to <see cref="Timeout.InfiniteTimeSpan" />, giving the possibility to debug without the timeout triggering.
/// </remarks>
public WaitForAssertionHelper(IRenderedFragmentBase renderedFragment, Action assertion, int maxRenderCount)
: base(
renderedFragment,
() =>
{
assertion();
return (true, default);
},
null,
maxRenderCount)
{ }
}
93 changes: 69 additions & 24 deletions src/bunit.core/Extensions/WaitForHelpers/WaitForHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,22 @@ namespace Bunit.Extensions.WaitForHelpers;
/// </summary>
public abstract class WaitForHelper<T> : IDisposable
{
private readonly Timer timer;
private readonly Timer? timer;
private readonly TaskCompletionSource<T> checkPassedCompletionSource;
private readonly Func<(bool CheckPassed, T Content)> completeChecker;
private readonly int? maxRenderCount;
private readonly IRenderedFragmentBase renderedFragment;
private readonly ILogger<WaitForHelper<T>> logger;
private readonly TestRenderer renderer;
private bool isDisposed;
private int checkCount;
private Exception? capturedException;

/// <summary>
/// Gets the error message passed to the user when the max render count passes.
/// </summary>
protected virtual string? MaxRenderCountErrorMessage { get; }

/// <summary>
/// Gets the error message passed to the user when the wait for helper times out.
/// </summary>
Expand Down Expand Up @@ -48,31 +54,36 @@ public abstract class WaitForHelper<T> : IDisposable
protected WaitForHelper(
IRenderedFragmentBase renderedFragment,
Func<(bool CheckPassed, T Content)> completeChecker,
TimeSpan? timeout = null)
TimeSpan? timeout = null,
int? maxRenderCount = null)
{
this.renderedFragment = renderedFragment ?? throw new ArgumentNullException(nameof(renderedFragment));
this.completeChecker = completeChecker ?? throw new ArgumentNullException(nameof(completeChecker));

logger = renderedFragment.Services.CreateLogger<WaitForHelper<T>>();
this.maxRenderCount = maxRenderCount;
renderer = (TestRenderer)renderedFragment
.Services
.GetRequiredService<TestContextBase>()
.Renderer;
checkPassedCompletionSource = new TaskCompletionSource<T>(TaskCreationOptions.RunContinuationsAsynchronously);
timer = new Timer(_ =>
{
logger.LogWaiterTimedOut(renderedFragment.ComponentId);
checkPassedCompletionSource.TrySetException(
new WaitForFailedException(
TimeoutErrorMessage ?? string.Empty,
checkCount,
renderedFragment.RenderCount,
renderer.RenderCount,
capturedException));
});
WaitTask = CreateWaitTask();
timer.Change(GetRuntimeTimeout(timeout), Timeout.InfiniteTimeSpan);

timer = new Timer(static (state) =>
{
var @this = (WaitForHelper<T>)state!;
@this.logger.LogWaiterTimedOut(@this.renderedFragment.ComponentId, @this.renderedFragment.RenderCount);
@this.checkPassedCompletionSource.TrySetException(
new WaitForFailedException(
@this.TimeoutErrorMessage ?? string.Empty,
@this.checkCount,
@this.renderedFragment.RenderCount,
@this.renderer.RenderCount,
@this.capturedException));
},
this,
GetRuntimeTimeout(timeout, maxRenderCount),
Timeout.InfiniteTimeSpan);

WaitTask = CreateWaitTask();
InitializeWaiting();
}

Expand Down Expand Up @@ -100,7 +111,7 @@ protected virtual void Dispose(bool disposing)
return;

isDisposed = true;
timer.Dispose();
timer?.Dispose();
checkPassedCompletionSource.TrySetCanceled();
renderedFragment.OnAfterRender -= OnAfterRender;
logger.LogWaiterDisposed(renderedFragment.ComponentId);
Expand Down Expand Up @@ -150,26 +161,38 @@ private void OnAfterRender(object? sender, EventArgs args)

try
{
logger.LogCheckingWaitCondition(renderedFragment.ComponentId);
logger.LogCheckingWaitCondition(renderedFragment.ComponentId, renderedFragment.RenderCount);

var checkResult = completeChecker();
checkCount++;
if (checkResult.CheckPassed)
{
checkPassedCompletionSource.TrySetResult(checkResult.Content);
logger.LogCheckCompleted(renderedFragment.ComponentId);
logger.LogCheckCompleted(renderedFragment.ComponentId, renderedFragment.RenderCount);
Dispose();
}
else if (MinimumRenderCountPassed())
{
checkPassedCompletionSource.TrySetException(
new WaitForFailedException(
MaxRenderCountErrorMessage ?? string.Empty,
checkCount,
renderedFragment.RenderCount,
renderer.RenderCount,
capturedException));

Dispose();
}
else
{
logger.LogCheckFailed(renderedFragment.ComponentId);
logger.LogCheckFailed(renderedFragment.ComponentId, renderedFragment.RenderCount);
}
}
catch (Exception ex)
{
checkCount++;
capturedException = ex;
logger.LogCheckThrow(renderedFragment.ComponentId, ex);
logger.LogCheckThrow(renderedFragment.ComponentId, renderedFragment.RenderCount, ex);

if (StopWaitingOnCheckException)
{
Expand All @@ -180,11 +203,28 @@ private void OnAfterRender(object? sender, EventArgs args)
renderedFragment.RenderCount,
renderer.RenderCount,
capturedException));

Dispose();
}

if (MinimumRenderCountPassed())
{
checkPassedCompletionSource.TrySetException(
new WaitForFailedException(
MaxRenderCountErrorMessage ?? string.Empty,
checkCount,
renderedFragment.RenderCount,
renderer.RenderCount,
capturedException));

Dispose();
}
}
}

private bool MinimumRenderCountPassed()
=> maxRenderCount.HasValue && renderedFragment.RenderCount >= maxRenderCount.Value;

private void SubscribeToOnAfterRender()
{
// There might not be a need to subscribe if the WaitTask has already
Expand All @@ -194,10 +234,15 @@ private void SubscribeToOnAfterRender()
renderedFragment.OnAfterRender += OnAfterRender;
}

private static TimeSpan GetRuntimeTimeout(TimeSpan? timeout)
private static TimeSpan GetRuntimeTimeout(TimeSpan? timeout, int? maxRenderCount)
{
return Debugger.IsAttached
? Timeout.InfiniteTimeSpan
if (Debugger.IsAttached)
{
return Timeout.InfiniteTimeSpan;
}

return maxRenderCount.HasValue
? TimeSpan.FromMinutes(1)
: timeout ?? TestContextBase.DefaultWaitTimeout;
}
}
Loading

0 comments on commit 727d382

Please sign in to comment.