diff --git a/.github/workflows/deploy-pages-test.yml b/.github/workflows/deploy-pages-test.yml
index 4ae2a0a1f9..261a136ea7 100644
--- a/.github/workflows/deploy-pages-test.yml
+++ b/.github/workflows/deploy-pages-test.yml
@@ -18,7 +18,7 @@ jobs:
fetch-depth: 0
- uses: actions/setup-node@v6
with:
- node-version: 22
+ node-version: 24
cache: yarn
cache-dependency-path: 'docs/yarn.lock'
diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml
index f3b44cd88a..e41288cdef 100644
--- a/.github/workflows/deploy-pages.yml
+++ b/.github/workflows/deploy-pages.yml
@@ -21,7 +21,7 @@ jobs:
fetch-depth: 0
- uses: actions/setup-node@v6
with:
- node-version: 22
+ node-version: 24
cache: yarn
cache-dependency-path: 'docs/yarn.lock'
diff --git a/Directory.Packages.props b/Directory.Packages.props
index e8332d9341..a2d4f48ddf 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -82,10 +82,10 @@
-
-
-
-
+
+
+
+
diff --git a/TUnit.Analyzers.Tests/DisposableFieldPropertyAnalyzerTests.cs b/TUnit.Analyzers.Tests/DisposableFieldPropertyAnalyzerTests.cs
index 2a9a31064a..54e85764fa 100644
--- a/TUnit.Analyzers.Tests/DisposableFieldPropertyAnalyzerTests.cs
+++ b/TUnit.Analyzers.Tests/DisposableFieldPropertyAnalyzerTests.cs
@@ -494,4 +494,92 @@ public void Test1()
"""
);
}
+
+
+ [Test]
+ [Arguments("Class")]
+ [Arguments("Assembly")]
+ [Arguments("TestSession")]
+ public async Task Bug3213(string hook)
+ {
+ await Verifier
+ .VerifyAnalyzerAsync(
+ $$"""
+ using System;
+ using System.Linq;
+ using System.Net;
+ using System.Net.Http;
+ using System.Threading;
+ using System.Threading.Tasks;
+ using TUnit.Core;
+
+ record RegisterPaymentHttp(string BookingId, string RoomId, decimal Amount, DateTimeOffset PaidAt);
+ record BookingState;
+ class Result { public class Ok { public HttpStatusCode StatusCode { get; set; } } }
+ class BookingEvents { public record BookingFullyPaid(DateTimeOffset PaidAt); }
+ class Booking { public object Payload { get; set; } = null!; }
+ class RestRequest {
+ public RestRequest(string path) { }
+ public RestRequest AddJsonBody(object obj) => this;
+ }
+ class ServerFixture {
+ public HttpClient GetClient() => null!;
+ public static BookRoom GetBookRoom() => null!;
+ public Task> ReadStream(string id) => Task.FromResult(Enumerable.Empty());
+ }
+ class BookRoom {
+ public string BookingId => string.Empty;
+ public string RoomId => string.Empty;
+ }
+ class TestEventListener : IDisposable {
+ public void Dispose() { }
+ }
+ static class HttpClientExtensions {
+ public static Task PostJsonAsync(this HttpClient client, string path, object body, CancellationToken cancellationToken) => Task.CompletedTask;
+ public static Task ExecutePostAsync(this HttpClient client, RestRequest request, CancellationToken cancellationToken) => Task.FromResult(default!);
+ }
+ static class ObjectExtensions {
+ public static void ShouldBe(this object obj, object expected) { }
+ public static void ShouldBeEquivalentTo(this object obj, object expected) { }
+ }
+
+ [ClassDataSource]
+ public class ControllerTests {
+ readonly ServerFixture _fixture = null!;
+
+ public ControllerTests(string value) {
+ }
+
+ [Test]
+ public async Task RecordPaymentUsingMappedCommand(CancellationToken cancellationToken) {
+ using var client = _fixture.GetClient();
+
+ var bookRoom = ServerFixture.GetBookRoom();
+
+ await client.PostJsonAsync("/book", bookRoom, cancellationToken: cancellationToken);
+
+ var registerPayment = new RegisterPaymentHttp(bookRoom.BookingId, bookRoom.RoomId, 100, DateTimeOffset.Now);
+
+ var request = new RestRequest("/v2/pay").AddJsonBody(registerPayment);
+ var response = await client.ExecutePostAsync.Ok>(request, cancellationToken: cancellationToken);
+ response.StatusCode.ShouldBe(HttpStatusCode.OK);
+
+ var expected = new BookingEvents.BookingFullyPaid(registerPayment.PaidAt);
+
+ var events = await _fixture.ReadStream(bookRoom.BookingId);
+ var last = events.LastOrDefault();
+ last!.Payload.ShouldBeEquivalentTo(expected);
+ }
+
+ static TestEventListener? listener;
+
+ [After(HookType.{{hook}})]
+ public static void Dispose() => listener?.Dispose();
+
+ [Before(HookType.{{hook}})]
+ public static void BeforeClass() => listener = new();
+ }
+ """
+ );
+ }
}
diff --git a/TUnit.Assertions.Tests/WaitsForAssertionTests.cs b/TUnit.Assertions.Tests/WaitsForAssertionTests.cs
new file mode 100644
index 0000000000..2e37d8c04b
--- /dev/null
+++ b/TUnit.Assertions.Tests/WaitsForAssertionTests.cs
@@ -0,0 +1,265 @@
+using System.Diagnostics;
+
+namespace TUnit.Assertions.Tests;
+
+[NotInParallel]
+public class WaitsForAssertionTests
+{
+ [Test]
+ public async Task WaitsFor_Passes_Immediately_When_Assertion_Succeeds()
+ {
+ var stopwatch = Stopwatch.StartNew();
+
+ var value = 42;
+ await Assert.That(value).WaitsFor(
+ assert => assert.IsEqualTo(42),
+ timeout: TimeSpan.FromSeconds(5));
+
+ stopwatch.Stop();
+
+ // Should complete very quickly since assertion passes immediately
+ await Assert.That(stopwatch.Elapsed).IsLessThan(TimeSpan.FromMilliseconds(100));
+ }
+
+ [Test]
+ public async Task WaitsFor_Passes_After_Multiple_Retries()
+ {
+ var counter = 0;
+ var stopwatch = Stopwatch.StartNew();
+
+ // Use a func that returns different values based on call count
+ Func getValue = () => Interlocked.Increment(ref counter);
+
+ await Assert.That(getValue).WaitsFor(
+ assert => assert.IsGreaterThan(3),
+ timeout: TimeSpan.FromSeconds(5),
+ pollingInterval: TimeSpan.FromMilliseconds(10));
+
+ stopwatch.Stop();
+
+ // Should have retried at least 3 times
+ await Assert.That(counter).IsGreaterThanOrEqualTo(4);
+ }
+
+ [Test]
+ public async Task WaitsFor_Fails_When_Timeout_Expires()
+ {
+ var stopwatch = Stopwatch.StartNew();
+ var value = 1;
+
+ var exception = await Assert.That(
+ async () => await Assert.That(value).WaitsFor(
+ assert => assert.IsEqualTo(999),
+ timeout: TimeSpan.FromMilliseconds(100),
+ pollingInterval: TimeSpan.FromMilliseconds(10))
+ ).Throws();
+
+ stopwatch.Stop();
+
+ // Verify timeout was respected (should be close to 100ms, not significantly longer)
+ await Assert.That(stopwatch.Elapsed).IsLessThan(TimeSpan.FromMilliseconds(200));
+
+ // Verify error message contains useful information
+ await Assert.That(exception.Message).Contains("assertion did not pass within 100ms");
+ await Assert.That(exception.Message).Contains("Last error:");
+ }
+
+ [Test]
+ public async Task WaitsFor_Supports_And_Chaining()
+ {
+ var value = 42;
+
+ // WaitsFor should support chaining with And
+ await Assert.That(value)
+ .WaitsFor(assert => assert.IsGreaterThan(40), timeout: TimeSpan.FromSeconds(1))
+ .And.IsLessThan(50);
+ }
+
+ [Test]
+ public async Task WaitsFor_Supports_Or_Chaining()
+ {
+ var value = 42;
+
+ // WaitsFor should support chaining with Or
+ await Assert.That(value)
+ .WaitsFor(assert => assert.IsEqualTo(42), timeout: TimeSpan.FromSeconds(1))
+ .Or.IsEqualTo(43);
+ }
+
+ [Test]
+ public async Task WaitsFor_With_Custom_Polling_Interval()
+ {
+ var counter = 0;
+ Func getValue = () => Interlocked.Increment(ref counter);
+
+ var stopwatch = Stopwatch.StartNew();
+
+ await Assert.That(getValue).WaitsFor(
+ assert => assert.IsGreaterThan(2),
+ timeout: TimeSpan.FromSeconds(1),
+ pollingInterval: TimeSpan.FromMilliseconds(50));
+
+ stopwatch.Stop();
+
+ // With 50ms interval and needing >2 (3rd increment), should take at least one polling interval
+ // The timing may vary slightly due to execution overhead, so we check for at least 40ms
+ await Assert.That(stopwatch.Elapsed).IsGreaterThan(TimeSpan.FromMilliseconds(40));
+
+ // Verify counter was actually incremented multiple times
+ await Assert.That(counter).IsGreaterThanOrEqualTo(3);
+ }
+
+ [Test]
+ public async Task WaitsFor_With_Eventually_Changing_Value()
+ {
+ var value = 0;
+
+ // Start a task that changes the value after 100ms
+ _ = Task.Run(async () =>
+ {
+ await Task.Delay(100);
+ Interlocked.Exchange(ref value, 42);
+ });
+
+ // WaitsFor should poll and eventually see the new value
+ await Assert.That(() => value).WaitsFor(
+ assert => assert.IsEqualTo(42),
+ timeout: TimeSpan.FromSeconds(5),
+ pollingInterval: TimeSpan.FromMilliseconds(10));
+ }
+
+ [Test]
+ public async Task WaitsFor_Works_With_Complex_Assertions()
+ {
+ var list = new List { 1, 2, 3 };
+
+ // Start a task that adds to the list after 50ms
+ _ = Task.Run(async () =>
+ {
+ await Task.Delay(50);
+ lock (list)
+ {
+ list.Add(4);
+ list.Add(5);
+ }
+ });
+
+ // Wait for list to have 5 items
+ await Assert.That(() =>
+ {
+ lock (list)
+ {
+ return list.Count;
+ }
+ }).WaitsFor(
+ assert => assert.IsEqualTo(5),
+ timeout: TimeSpan.FromSeconds(5),
+ pollingInterval: TimeSpan.FromMilliseconds(10));
+ }
+
+ [Test]
+ public async Task WaitsFor_Throws_ArgumentException_For_Zero_Timeout()
+ {
+ var value = 42;
+
+#pragma warning disable TUnitAssertions0002 // Testing constructor exception, not awaiting
+ var exception = Assert.Throws(() =>
+ Assert.That(value).WaitsFor(
+ assert => assert.IsEqualTo(42),
+ timeout: TimeSpan.Zero));
+#pragma warning restore TUnitAssertions0002
+
+ await Assert.That(exception.Message).Contains("Timeout must be positive");
+ }
+
+ [Test]
+ public async Task WaitsFor_Throws_ArgumentException_For_Negative_Timeout()
+ {
+ var value = 42;
+
+#pragma warning disable TUnitAssertions0002 // Testing constructor exception, not awaiting
+ var exception = Assert.Throws(() =>
+ Assert.That(value).WaitsFor(
+ assert => assert.IsEqualTo(42),
+ timeout: TimeSpan.FromSeconds(-1)));
+#pragma warning restore TUnitAssertions0002
+
+ await Assert.That(exception.Message).Contains("Timeout must be positive");
+ }
+
+ [Test]
+ public async Task WaitsFor_Throws_ArgumentException_For_Zero_PollingInterval()
+ {
+ var value = 42;
+
+#pragma warning disable TUnitAssertions0002 // Testing constructor exception, not awaiting
+ var exception = Assert.Throws(() =>
+ Assert.That(value).WaitsFor(
+ assert => assert.IsEqualTo(42),
+ timeout: TimeSpan.FromSeconds(1),
+ pollingInterval: TimeSpan.Zero));
+#pragma warning restore TUnitAssertions0002
+
+ await Assert.That(exception.Message).Contains("Polling interval must be positive");
+ }
+
+ [Test]
+ public async Task WaitsFor_Throws_ArgumentNullException_For_Null_AssertionBuilder()
+ {
+ var value = 42;
+
+#pragma warning disable TUnitAssertions0002 // Testing constructor exception, not awaiting
+ var exception = Assert.Throws(() =>
+ Assert.That(value).WaitsFor(
+ assertionBuilder: null!,
+ timeout: TimeSpan.FromSeconds(1)));
+#pragma warning restore TUnitAssertions0002
+
+ await Assert.That(exception.ParamName).IsEqualTo("assertionBuilder");
+ }
+
+ [Test]
+ public async Task WaitsFor_Real_World_Scenario_GPIO_Event()
+ {
+ // Simulate the real-world scenario from the GitHub issue:
+ // Testing GPIO events that take time to propagate
+
+ var pinValue = false;
+
+ // Simulate an async GPIO event that changes state after 75ms
+ _ = Task.Run(async () =>
+ {
+ await Task.Delay(75);
+ pinValue = true;
+ });
+
+ // Wait for the pin to become true
+ await Assert.That(() => pinValue).WaitsFor(
+ assert => assert.IsEqualTo(true),
+ timeout: TimeSpan.FromSeconds(2),
+ pollingInterval: TimeSpan.FromMilliseconds(10));
+ }
+
+ [Test]
+ public async Task WaitsFor_Performance_Many_Quick_Polls()
+ {
+ var counter = 0;
+ var stopwatch = Stopwatch.StartNew();
+
+ // This will take many polls before succeeding
+ Func getValue = () => Interlocked.Increment(ref counter);
+
+ await Assert.That(getValue).WaitsFor(
+ assert => assert.IsGreaterThan(100),
+ timeout: TimeSpan.FromSeconds(5),
+ pollingInterval: TimeSpan.FromMilliseconds(1));
+
+ stopwatch.Stop();
+
+ // Should have made at least 100 attempts
+ await Assert.That(counter).IsGreaterThanOrEqualTo(101);
+
+ // Should complete in a reasonable time (well under 5 seconds)
+ await Assert.That(stopwatch.Elapsed).IsLessThan(TimeSpan.FromSeconds(2));
+ }
+}
diff --git a/TUnit.Assertions/Conditions/WaitsForAssertion.cs b/TUnit.Assertions/Conditions/WaitsForAssertion.cs
new file mode 100644
index 0000000000..3629d2a297
--- /dev/null
+++ b/TUnit.Assertions/Conditions/WaitsForAssertion.cs
@@ -0,0 +1,113 @@
+using System.Diagnostics;
+using TUnit.Assertions.Core;
+using TUnit.Assertions.Exceptions;
+using TUnit.Assertions.Sources;
+
+namespace TUnit.Assertions.Conditions;
+
+///
+/// Asserts that an assertion passes within a specified timeout by polling repeatedly.
+/// Useful for testing asynchronous or event-driven code where state changes take time to propagate.
+///
+/// The type of value being asserted
+public class WaitsForAssertion : Assertion
+{
+ private readonly Func, Assertion> _assertionBuilder;
+ private readonly TimeSpan _timeout;
+ private readonly TimeSpan _pollingInterval;
+
+ public WaitsForAssertion(
+ AssertionContext context,
+ Func, Assertion> assertionBuilder,
+ TimeSpan timeout,
+ TimeSpan? pollingInterval = null)
+ : base(context)
+ {
+ _assertionBuilder = assertionBuilder ?? throw new ArgumentNullException(nameof(assertionBuilder));
+ _timeout = timeout;
+ _pollingInterval = pollingInterval ?? TimeSpan.FromMilliseconds(10);
+
+ if (_timeout <= TimeSpan.Zero)
+ {
+ throw new ArgumentException("Timeout must be positive", nameof(timeout));
+ }
+
+ if (_pollingInterval <= TimeSpan.Zero)
+ {
+ throw new ArgumentException("Polling interval must be positive", nameof(pollingInterval));
+ }
+ }
+
+ protected override async Task CheckAsync(EvaluationMetadata metadata)
+ {
+ var stopwatch = Stopwatch.StartNew();
+ Exception? lastException = null;
+ var attemptCount = 0;
+
+ using var cts = new CancellationTokenSource(_timeout);
+
+ while (stopwatch.Elapsed < _timeout)
+ {
+ attemptCount++;
+
+ try
+ {
+ var (currentValue, currentException) = await Context.Evaluation.ReevaluateAsync();
+ var assertionSource = new ValueAssertion(currentValue, "polled value");
+ var assertion = _assertionBuilder(assertionSource);
+ await assertion.AssertAsync();
+
+ return AssertionResult.Passed;
+ }
+ catch (AssertionException ex)
+ {
+ lastException = ex;
+
+ // Check if we've exceeded timeout before waiting
+ if (stopwatch.Elapsed + _pollingInterval >= _timeout)
+ {
+ break;
+ }
+
+ try
+ {
+ await Task.Delay(_pollingInterval, cts.Token);
+ }
+ catch (OperationCanceledException)
+ {
+ break;
+ }
+ }
+ }
+
+ stopwatch.Stop();
+
+ var lastErrorMessage = lastException != null
+ ? $"Last error: {ExtractAssertionMessage(lastException)}"
+ : "No attempts were made";
+
+ return AssertionResult.Failed(
+ $"assertion did not pass within {_timeout.TotalMilliseconds:F0}ms after {attemptCount} attempts. {lastErrorMessage}");
+ }
+
+ protected override string GetExpectation() =>
+ $"assertion to pass within {_timeout.TotalMilliseconds:F0} milliseconds " +
+ $"(polling every {_pollingInterval.TotalMilliseconds:F0}ms)";
+
+ ///
+ /// Extracts the core assertion message from an exception,
+ /// removing the stack trace and location info for cleaner output.
+ ///
+ private static string ExtractAssertionMessage(Exception exception)
+ {
+ var message = exception.Message;
+ var atIndex = message.IndexOf("\nat Assert.That", StringComparison.Ordinal);
+
+ if (atIndex > 0)
+ {
+ message = message.Substring(0, atIndex).Trim();
+ }
+
+ return message;
+ }
+}
diff --git a/TUnit.Assertions/Core/EvaluationContext.cs b/TUnit.Assertions/Core/EvaluationContext.cs
index 93beef0113..94c9432fff 100644
--- a/TUnit.Assertions/Core/EvaluationContext.cs
+++ b/TUnit.Assertions/Core/EvaluationContext.cs
@@ -49,6 +49,32 @@ public EvaluationContext(TValue? value)
return (_value, _exception);
}
+ ///
+ /// Re-evaluates the source by bypassing the cache and invoking the evaluator again.
+ /// Used by polling assertions like WaitsFor that need to observe changing values.
+ /// For immediate values (created without an evaluator), returns the cached value.
+ ///
+ /// The freshly evaluated value and any exception that occurred
+ public async Task<(TValue? Value, Exception? Exception)> ReevaluateAsync()
+ {
+ if (_evaluator == null)
+ {
+ return (_value, _exception);
+ }
+
+ var startTime = DateTimeOffset.Now;
+ var (value, exception) = await _evaluator();
+ var endTime = DateTimeOffset.Now;
+
+ _value = value;
+ _exception = exception;
+ _startTime = startTime;
+ _endTime = endTime;
+ _evaluated = true;
+
+ return (value, exception);
+ }
+
///
/// Creates a derived context by mapping the value to a different type.
/// Used for type transformations like IsTypeOf<T>().
diff --git a/TUnit.Assertions/Extensions/AssertionExtensions.cs b/TUnit.Assertions/Extensions/AssertionExtensions.cs
index 487243a262..fdcb2916f3 100644
--- a/TUnit.Assertions/Extensions/AssertionExtensions.cs
+++ b/TUnit.Assertions/Extensions/AssertionExtensions.cs
@@ -1408,6 +1408,33 @@ public static CompletesWithinAsyncAssertion CompletesWithin(
return new CompletesWithinAsyncAssertion(asyncAction, timeout);
}
+ ///
+ /// Asserts that an assertion passes within the specified timeout by polling repeatedly.
+ /// The assertion builder is invoked on each polling attempt until it passes or the timeout expires.
+ /// Useful for testing asynchronous or event-driven code where state changes take time to propagate.
+ /// Example: await Assert.That(value).WaitsFor(assert => assert.IsEqualTo(2), timeout: TimeSpan.FromSeconds(5));
+ ///
+ /// The type of value being asserted
+ /// The assertion source
+ /// A function that builds the assertion to be evaluated on each poll
+ /// The maximum time to wait for the assertion to pass
+ /// The interval between polling attempts (defaults to 10ms if not specified)
+ /// Captured expression for the timeout parameter
+ /// Captured expression for the polling interval parameter
+ /// An assertion that can be awaited or chained with And/Or
+ public static WaitsForAssertion WaitsFor(
+ this IAssertionSource source,
+ Func, Assertion> assertionBuilder,
+ TimeSpan timeout,
+ TimeSpan? pollingInterval = null,
+ [CallerArgumentExpression(nameof(timeout))] string? timeoutExpression = null,
+ [CallerArgumentExpression(nameof(pollingInterval))] string? pollingIntervalExpression = null)
+ {
+ var intervalExpr = pollingInterval.HasValue ? $", pollingInterval: {pollingIntervalExpression}" : "";
+ source.Context.ExpressionBuilder.Append($".WaitsFor(..., timeout: {timeoutExpression}{intervalExpr})");
+ return new WaitsForAssertion(source.Context, assertionBuilder, timeout, pollingInterval);
+ }
+
private static Action GetActionFromDelegate(DelegateAssertion source)
{
return source.Action;
diff --git a/TUnit.Engine/Building/Interfaces/ITestBuilder.cs b/TUnit.Engine/Building/Interfaces/ITestBuilder.cs
index e2066eadff..c50bda428e 100644
--- a/TUnit.Engine/Building/Interfaces/ITestBuilder.cs
+++ b/TUnit.Engine/Building/Interfaces/ITestBuilder.cs
@@ -22,11 +22,12 @@ internal interface ITestBuilder
/// This is the main method that replaces the old DataSourceExpander approach.
///
/// The test metadata with DataCombinationGenerator
+ /// Context for optimizing test building (e.g., pre-filtering during execution)
/// Collection of executable tests for all data combinations
#if NET6_0_OR_GREATER
[RequiresUnreferencedCode("Test building in reflection mode uses generic type resolution which requires unreferenced code")]
#endif
- Task> BuildTestsFromMetadataAsync(TestMetadata metadata);
+ Task> BuildTestsFromMetadataAsync(TestMetadata metadata, TestBuildingContext buildingContext);
///
/// Streaming version that yields tests as they're built without buffering
diff --git a/TUnit.Engine/Building/TestBuilder.cs b/TUnit.Engine/Building/TestBuilder.cs
index 11ed1b0075..78fa82ca94 100644
--- a/TUnit.Engine/Building/TestBuilder.cs
+++ b/TUnit.Engine/Building/TestBuilder.cs
@@ -1,4 +1,6 @@
using System.Diagnostics.CodeAnalysis;
+using Microsoft.Testing.Platform.Extensions.Messages;
+using Microsoft.Testing.Platform.Requests;
using TUnit.Core;
using TUnit.Core.Enums;
using TUnit.Core.Exceptions;
@@ -114,8 +116,18 @@ private async Task