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 CreateInstance(TestMetadata metadata, Type[] resolved #if NET6_0_OR_GREATER [RequiresUnreferencedCode("Test building in reflection mode uses generic type resolution which requires unreferenced code")] #endif - public async Task> BuildTestsFromMetadataAsync(TestMetadata metadata) + public async Task> BuildTestsFromMetadataAsync(TestMetadata metadata, TestBuildingContext buildingContext) { + // OPTIMIZATION: Pre-filter in execution mode to skip building tests that cannot match the filter + if (buildingContext.IsForExecution && buildingContext.Filter != null) + { + if (!CouldTestMatchFilter(buildingContext.Filter, metadata)) + { + // This test class cannot match the filter - skip all expensive work! + return Array.Empty(); + } + } + var tests = new List(); try @@ -126,7 +138,7 @@ public async Task> BuildTestsFromMetadataAsy // Build tests from each concrete instantiation foreach (var concreteMetadata in genericMetadata.ConcreteInstantiations.Values) { - var concreteTests = await BuildTestsFromMetadataAsync(concreteMetadata); + var concreteTests = await BuildTestsFromMetadataAsync(concreteMetadata, buildingContext); tests.AddRange(concreteTests); } return tests; @@ -1563,4 +1575,111 @@ public async IAsyncEnumerable BuildTestsStreamingAsync( return await CreateFailedTestForDataGenerationError(metadata, ex); } } + + /// + /// Determines if a test could potentially match the filter without building the full test object. + /// This is a conservative check - returns true unless we can definitively rule out the test. + /// + private bool CouldTestMatchFilter(ITestExecutionFilter filter, TestMetadata metadata) + { +#pragma warning disable TPEXP + return filter switch + { + null => true, + NopFilter => true, + TreeNodeFilter treeFilter => CouldMatchTreeNodeFilter(treeFilter, metadata), + TestNodeUidListFilter uidFilter => CouldMatchUidFilter(uidFilter, metadata), + _ => true // Unknown filter type - be conservative + }; +#pragma warning restore TPEXP + } + + /// + /// Checks if a test could match a TestNodeUidListFilter by checking if any UID contains + /// the namespace, class name, and method name. + /// + private static bool CouldMatchUidFilter(TestNodeUidListFilter filter, TestMetadata metadata) + { + var classMetadata = metadata.MethodMetadata.Class; + var namespaceName = classMetadata.Namespace ?? ""; + var className = metadata.TestClassType.Name; + var methodName = metadata.TestMethodName; + + // Check if any UID in the filter contains all three components + foreach (var uid in filter.TestNodeUids) + { + var uidValue = uid.Value; + if (uidValue.Contains(namespaceName) && + uidValue.Contains(className) && + uidValue.Contains(methodName)) + { + return true; + } + } + + return false; + } + + /// + /// Checks if a test could match a TreeNodeFilter by building the test path and checking the filter. + /// +#pragma warning disable TPEXP + private bool CouldMatchTreeNodeFilter(TreeNodeFilter filter, TestMetadata metadata) + { + var filterString = filter.Filter; + + // No filter means match all + if (string.IsNullOrEmpty(filterString)) + { + return true; + } + + // If the filter contains property conditions, strip them for path-only matching + // Property conditions will be evaluated in the second pass after tests are fully built + TreeNodeFilter pathOnlyFilter; + if (filterString.Contains('[')) + { + // Strip all property conditions: [key=value] + // Use regex to remove all [...] blocks + var strippedFilterString = System.Text.RegularExpressions.Regex.Replace(filterString, @"\[([^\]]*)\]", ""); + + // Create a new TreeNodeFilter with the stripped filter string using reflection + pathOnlyFilter = CreateTreeNodeFilterViaReflection(strippedFilterString); + } + else + { + pathOnlyFilter = filter; + } + + var path = BuildPathFromMetadata(metadata); + var emptyPropertyBag = new PropertyBag(); + return pathOnlyFilter.MatchesFilter(path, emptyPropertyBag); + } + + /// + /// Creates a TreeNodeFilter instance via reflection since it doesn't have a public constructor. + /// + private static TreeNodeFilter CreateTreeNodeFilterViaReflection(string filterString) + { + var constructor = typeof(TreeNodeFilter).GetConstructors( + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)[0]; + + return (TreeNodeFilter)constructor.Invoke(new object[] { filterString }); + } +#pragma warning restore TPEXP + + /// + /// Builds the test path from metadata, matching the format used by TestFilterService. + /// Path format: /AssemblyName/Namespace/ClassName/MethodName + /// + private static string BuildPathFromMetadata(TestMetadata metadata) + { + var classMetadata = metadata.MethodMetadata.Class; + var assemblyName = classMetadata.Assembly.Name ?? metadata.TestClassType.Assembly.GetName().Name ?? "*"; + var namespaceName = classMetadata.Namespace ?? "*"; + var className = classMetadata.Name; + var methodName = metadata.TestMethodName; + + return $"/{assemblyName}/{namespaceName}/{className}/{methodName}"; + } } diff --git a/TUnit.Engine/Building/TestBuilderPipeline.cs b/TUnit.Engine/Building/TestBuilderPipeline.cs index 1ccb75146d..ddeb311691 100644 --- a/TUnit.Engine/Building/TestBuilderPipeline.cs +++ b/TUnit.Engine/Building/TestBuilderPipeline.cs @@ -61,7 +61,9 @@ public async Task> BuildTestsAsync(string te { var collectedMetadata = await _dataCollector.CollectTestsAsync(testSessionId).ConfigureAwait(false); - return await BuildTestsFromMetadataAsync(collectedMetadata).ConfigureAwait(false); + // For this method (non-streaming), we're not in execution mode so no filter optimization + var buildingContext = new TestBuildingContext(IsForExecution: false, Filter: null); + return await BuildTestsFromMetadataAsync(collectedMetadata, buildingContext).ConfigureAwait(false); } /// @@ -71,6 +73,7 @@ public async Task> BuildTestsAsync(string te [UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Reflection mode is not used in AOT scenarios")] public async Task> BuildTestsStreamingAsync( string testSessionId, + TestBuildingContext buildingContext, CancellationToken cancellationToken = default) { // Get metadata streaming if supported @@ -78,7 +81,7 @@ public async Task> BuildTestsStreamingAsync( var collectedMetadata = await _dataCollector.CollectTestsAsync(testSessionId).ConfigureAwait(false); return await collectedMetadata - .SelectManyAsync(BuildTestsFromSingleMetadataAsync, cancellationToken: cancellationToken) + .SelectManyAsync(metadata => BuildTestsFromSingleMetadataAsync(metadata, buildingContext), cancellationToken: cancellationToken) .ProcessInParallel(cancellationToken: cancellationToken); } @@ -93,7 +96,7 @@ private async IAsyncEnumerable ToAsyncEnumerable(IEnumerable> BuildTestsFromMetadataAsync(IEnumerable testMetadata) + public async Task> BuildTestsFromMetadataAsync(IEnumerable testMetadata, TestBuildingContext buildingContext) { var testGroups = await testMetadata.SelectAsync(async metadata => { @@ -105,7 +108,7 @@ public async Task> BuildTestsFromMetadataAsy return await GenerateDynamicTests(metadata).ConfigureAwait(false); } - return await _testBuilder.BuildTestsFromMetadataAsync(metadata).ConfigureAwait(false); + return await _testBuilder.BuildTestsFromMetadataAsync(metadata, buildingContext).ConfigureAwait(false); } catch (Exception ex) { @@ -210,7 +213,7 @@ private async Task GenerateDynamicTests(TestMetadata m #if NET6_0_OR_GREATER [RequiresUnreferencedCode("Test building in reflection mode uses generic type resolution which requires unreferenced code")] #endif - private async IAsyncEnumerable BuildTestsFromSingleMetadataAsync(TestMetadata metadata) + private async IAsyncEnumerable BuildTestsFromSingleMetadataAsync(TestMetadata metadata, TestBuildingContext buildingContext) { TestMetadata resolvedMetadata; Exception? resolutionError = null; @@ -324,7 +327,7 @@ private async IAsyncEnumerable BuildTestsFromSingleMetad else { // Normal test metadata goes through the standard test builder - var testsFromMetadata = await _testBuilder.BuildTestsFromMetadataAsync(resolvedMetadata).ConfigureAwait(false); + var testsFromMetadata = await _testBuilder.BuildTestsFromMetadataAsync(resolvedMetadata, buildingContext).ConfigureAwait(false); testsToYield = new List(testsFromMetadata); } } diff --git a/TUnit.Engine/Building/TestBuildingContext.cs b/TUnit.Engine/Building/TestBuildingContext.cs new file mode 100644 index 0000000000..b48669f9af --- /dev/null +++ b/TUnit.Engine/Building/TestBuildingContext.cs @@ -0,0 +1,19 @@ +using Microsoft.Testing.Platform.Requests; + +namespace TUnit.Engine.Building; + +/// +/// Context information for building tests, used to optimize test discovery and execution. +/// +internal record TestBuildingContext( + /// + /// Indicates whether tests are being built for execution (true) or discovery/display (false). + /// When true, optimizations like early filtering can be applied. + /// + bool IsForExecution, + + /// + /// The filter to apply during test building. Only relevant when IsForExecution is true. + /// + ITestExecutionFilter? Filter +); diff --git a/TUnit.Engine/Services/TestRegistry.cs b/TUnit.Engine/Services/TestRegistry.cs index 131c9feb58..737998f8f5 100644 --- a/TUnit.Engine/Services/TestRegistry.cs +++ b/TUnit.Engine/Services/TestRegistry.cs @@ -90,7 +90,9 @@ private async Task ProcessPendingDynamicTests() testMetadataList.Add(metadata); } - var builtTests = await _testBuilderPipeline!.BuildTestsFromMetadataAsync(testMetadataList); + // These are dynamic tests registered after discovery, so not in execution mode with a filter + var buildingContext = new Building.TestBuildingContext(IsForExecution: false, Filter: null); + var builtTests = await _testBuilderPipeline!.BuildTestsFromMetadataAsync(testMetadataList, buildingContext); foreach (var test in builtTests) { diff --git a/TUnit.Engine/TestDiscoveryService.cs b/TUnit.Engine/TestDiscoveryService.cs index 05de9b3332..9d40643ac2 100644 --- a/TUnit.Engine/TestDiscoveryService.cs +++ b/TUnit.Engine/TestDiscoveryService.cs @@ -55,12 +55,15 @@ public async Task DiscoverTests(string testSessionId, ITest contextProvider.BeforeTestDiscoveryContext.RestoreExecutionContext(); + // Create building context for optimization + var buildingContext = new Building.TestBuildingContext(isForExecution, filter); + // Stage 1: Stream independent tests immediately while buffering dependent tests var independentTests = new List(); var dependentTests = new List(); var allTests = new List(); - await foreach (var test in DiscoverTestsStreamAsync(testSessionId, cancellationToken).ConfigureAwait(false)) + await foreach (var test in DiscoverTestsStreamAsync(testSessionId, buildingContext, cancellationToken).ConfigureAwait(false)) { allTests.Add(test); @@ -131,6 +134,7 @@ public async Task DiscoverTests(string testSessionId, ITest /// Streams test discovery for parallel discovery and execution private async IAsyncEnumerable DiscoverTestsStreamAsync( string testSessionId, + Building.TestBuildingContext buildingContext, [EnumeratorCancellation] CancellationToken cancellationToken = default) { using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); @@ -138,7 +142,7 @@ private async IAsyncEnumerable DiscoverTestsStreamAsync( // Set a reasonable timeout for test discovery (5 minutes) cts.CancelAfter(TimeSpan.FromMinutes(5)); - var tests = await _testBuilderPipeline.BuildTestsStreamingAsync(testSessionId, cancellationToken).ConfigureAwait(false); + var tests = await _testBuilderPipeline.BuildTestsStreamingAsync(testSessionId, buildingContext, cancellationToken).ConfigureAwait(false); foreach (var test in tests) { @@ -164,9 +168,12 @@ public async IAsyncEnumerable DiscoverTestsFullyStreamin { await _testExecutor.ExecuteBeforeTestDiscoveryHooksAsync(cancellationToken).ConfigureAwait(false); + // Create building context - this is for discovery/streaming, not execution filtering + var buildingContext = new Building.TestBuildingContext(IsForExecution: false, Filter: null); + // Collect all tests first (like source generation mode does) var allTests = new List(); - await foreach (var test in DiscoverTestsStreamAsync(testSessionId, cancellationToken).ConfigureAwait(false)) + await foreach (var test in DiscoverTestsStreamAsync(testSessionId, buildingContext, cancellationToken).ConfigureAwait(false)) { allTests.Add(test); } diff --git a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet10_0.verified.txt b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet10_0.verified.txt index cbef291244..32d6a99f6e 100644 --- a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet10_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet10_0.verified.txt @@ -1591,6 +1591,12 @@ namespace .Conditions [.(ExpectationMessage="to not be a major version")] public static bool IsNotMajorVersion(this value) { } } + public class WaitsForAssertion : . + { + public WaitsForAssertion(. context, <., .> assertionBuilder, timeout, ? pollingInterval = default) { } + protected override .<.> CheckAsync(. metadata) { } + protected override string GetExpectation() { } + } [.<>("IsAlive", CustomName="IsNotAlive", ExpectationMessage="be alive", NegateLogic=true)] [.<>("IsAlive", ExpectationMessage="be alive")] [.<>("TrackResurrection", CustomName="DoesNotTrackResurrection", ExpectationMessage="track resurrection", NegateLogic=true)] @@ -1714,6 +1720,10 @@ namespace .Core public . Map( mapper) { } public . MapException() where TException : { } + [return: .(new string?[]?[] { + "Value", + "Exception"})] + public .<> ReevaluateAsync() { } } public readonly struct EvaluationMetadata { @@ -1952,6 +1962,7 @@ namespace .Extensions public static . ThrowsException(this . source) where TException : { } public static . ThrowsNothing(this . source) { } + public static . WaitsFor(this . source, <., .> assertionBuilder, timeout, ? pollingInterval = default, [.("timeout")] string? timeoutExpression = null, [.("pollingInterval")] string? pollingIntervalExpression = null) { } public static ..WhenParsedIntoAssertion WhenParsedInto<[.(..None | ..PublicMethods | ..Interfaces)] T>(this . source) { } public static . WithMessage(this . source, string expectedMessage, [.("expectedMessage")] string? expression = null) where TException : { } diff --git a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet8_0.verified.txt b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet8_0.verified.txt index 13d23a7731..d0e272c035 100644 --- a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet8_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet8_0.verified.txt @@ -1588,6 +1588,12 @@ namespace .Conditions [.(ExpectationMessage="to not be a major version")] public static bool IsNotMajorVersion(this value) { } } + public class WaitsForAssertion : . + { + public WaitsForAssertion(. context, <., .> assertionBuilder, timeout, ? pollingInterval = default) { } + protected override .<.> CheckAsync(. metadata) { } + protected override string GetExpectation() { } + } [.<>("IsAlive", CustomName="IsNotAlive", ExpectationMessage="be alive", NegateLogic=true)] [.<>("IsAlive", ExpectationMessage="be alive")] [.<>("TrackResurrection", CustomName="DoesNotTrackResurrection", ExpectationMessage="track resurrection", NegateLogic=true)] @@ -1711,6 +1717,10 @@ namespace .Core public . Map( mapper) { } public . MapException() where TException : { } + [return: .(new string?[]?[] { + "Value", + "Exception"})] + public .<> ReevaluateAsync() { } } public readonly struct EvaluationMetadata { @@ -1942,6 +1952,7 @@ namespace .Extensions public static . ThrowsException(this . source) where TException : { } public static . ThrowsNothing(this . source) { } + public static . WaitsFor(this . source, <., .> assertionBuilder, timeout, ? pollingInterval = default, [.("timeout")] string? timeoutExpression = null, [.("pollingInterval")] string? pollingIntervalExpression = null) { } public static ..WhenParsedIntoAssertion WhenParsedInto<[.(..None | ..PublicMethods | ..Interfaces)] T>(this . source) { } public static . WithMessage(this . source, string expectedMessage, [.("expectedMessage")] string? expression = null) where TException : { } diff --git a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet9_0.verified.txt b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet9_0.verified.txt index 9adf2916fc..dc4a6d144e 100644 --- a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet9_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet9_0.verified.txt @@ -1591,6 +1591,12 @@ namespace .Conditions [.(ExpectationMessage="to not be a major version")] public static bool IsNotMajorVersion(this value) { } } + public class WaitsForAssertion : . + { + public WaitsForAssertion(. context, <., .> assertionBuilder, timeout, ? pollingInterval = default) { } + protected override .<.> CheckAsync(. metadata) { } + protected override string GetExpectation() { } + } [.<>("IsAlive", CustomName="IsNotAlive", ExpectationMessage="be alive", NegateLogic=true)] [.<>("IsAlive", ExpectationMessage="be alive")] [.<>("TrackResurrection", CustomName="DoesNotTrackResurrection", ExpectationMessage="track resurrection", NegateLogic=true)] @@ -1714,6 +1720,10 @@ namespace .Core public . Map( mapper) { } public . MapException() where TException : { } + [return: .(new string?[]?[] { + "Value", + "Exception"})] + public .<> ReevaluateAsync() { } } public readonly struct EvaluationMetadata { @@ -1952,6 +1962,7 @@ namespace .Extensions public static . ThrowsException(this . source) where TException : { } public static . ThrowsNothing(this . source) { } + public static . WaitsFor(this . source, <., .> assertionBuilder, timeout, ? pollingInterval = default, [.("timeout")] string? timeoutExpression = null, [.("pollingInterval")] string? pollingIntervalExpression = null) { } public static ..WhenParsedIntoAssertion WhenParsedInto<[.(..None | ..PublicMethods | ..Interfaces)] T>(this . source) { } public static . WithMessage(this . source, string expectedMessage, [.("expectedMessage")] string? expression = null) where TException : { } diff --git a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.Net4_7.verified.txt b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.Net4_7.verified.txt index a2f6c9c0ea..d875beeb7d 100644 --- a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.Net4_7.verified.txt +++ b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.Net4_7.verified.txt @@ -1492,6 +1492,12 @@ namespace .Conditions [.(ExpectationMessage="to not be a major version")] public static bool IsNotMajorVersion(this value) { } } + public class WaitsForAssertion : . + { + public WaitsForAssertion(. context, <., .> assertionBuilder, timeout, ? pollingInterval = default) { } + protected override .<.> CheckAsync(. metadata) { } + protected override string GetExpectation() { } + } [.<>("IsAlive", CustomName="IsNotAlive", ExpectationMessage="be alive", NegateLogic=true)] [.<>("IsAlive", ExpectationMessage="be alive")] [.<>("TrackResurrection", CustomName="DoesNotTrackResurrection", ExpectationMessage="track resurrection", NegateLogic=true)] @@ -1613,6 +1619,10 @@ namespace .Core public . Map( mapper) { } public . MapException() where TException : { } + [return: .(new string?[]?[] { + "Value", + "Exception"})] + public .<> ReevaluateAsync() { } } public readonly struct EvaluationMetadata { @@ -1814,6 +1824,7 @@ namespace .Extensions public static . ThrowsException(this . source) where TException : { } public static . ThrowsNothing(this . source) { } + public static . WaitsFor(this . source, <., .> assertionBuilder, timeout, ? pollingInterval = default, [.("timeout")] string? timeoutExpression = null, [.("pollingInterval")] string? pollingIntervalExpression = null) { } public static ..WhenParsedIntoAssertion WhenParsedInto(this . source) { } public static . WithMessage(this . source, string expectedMessage, [.("expectedMessage")] string? expression = null) where TException : { } diff --git a/TUnit.Templates/content/TUnit.AspNet.FSharp/TestProject/TestProject.fsproj b/TUnit.Templates/content/TUnit.AspNet.FSharp/TestProject/TestProject.fsproj index 2b80e58a62..88df95cefc 100644 --- a/TUnit.Templates/content/TUnit.AspNet.FSharp/TestProject/TestProject.fsproj +++ b/TUnit.Templates/content/TUnit.AspNet.FSharp/TestProject/TestProject.fsproj @@ -10,8 +10,8 @@ - - + + diff --git a/TUnit.Templates/content/TUnit.AspNet/TestProject/TestProject.csproj b/TUnit.Templates/content/TUnit.AspNet/TestProject/TestProject.csproj index 08d17501b4..43fbea50cc 100644 --- a/TUnit.Templates/content/TUnit.AspNet/TestProject/TestProject.csproj +++ b/TUnit.Templates/content/TUnit.AspNet/TestProject/TestProject.csproj @@ -9,7 +9,7 @@ - + diff --git a/TUnit.Templates/content/TUnit.Aspire.Starter/ExampleNamespace.TestProject/ExampleNamespace.TestProject.csproj b/TUnit.Templates/content/TUnit.Aspire.Starter/ExampleNamespace.TestProject/ExampleNamespace.TestProject.csproj index dce660bd96..3ab8921c1b 100644 --- a/TUnit.Templates/content/TUnit.Aspire.Starter/ExampleNamespace.TestProject/ExampleNamespace.TestProject.csproj +++ b/TUnit.Templates/content/TUnit.Aspire.Starter/ExampleNamespace.TestProject/ExampleNamespace.TestProject.csproj @@ -11,7 +11,7 @@ - + diff --git a/TUnit.Templates/content/TUnit.Aspire.Test/ExampleNamespace.csproj b/TUnit.Templates/content/TUnit.Aspire.Test/ExampleNamespace.csproj index 95d7a78f4c..e0e5e43f05 100644 --- a/TUnit.Templates/content/TUnit.Aspire.Test/ExampleNamespace.csproj +++ b/TUnit.Templates/content/TUnit.Aspire.Test/ExampleNamespace.csproj @@ -10,7 +10,7 @@ - + diff --git a/TUnit.Templates/content/TUnit.FSharp/TestProject.fsproj b/TUnit.Templates/content/TUnit.FSharp/TestProject.fsproj index 3ee8ed4d7c..f5b9826038 100644 --- a/TUnit.Templates/content/TUnit.FSharp/TestProject.fsproj +++ b/TUnit.Templates/content/TUnit.FSharp/TestProject.fsproj @@ -10,8 +10,8 @@ - - + + diff --git a/TUnit.Templates/content/TUnit.Playwright/TestProject.csproj b/TUnit.Templates/content/TUnit.Playwright/TestProject.csproj index 8495b0595a..56c7f5808c 100644 --- a/TUnit.Templates/content/TUnit.Playwright/TestProject.csproj +++ b/TUnit.Templates/content/TUnit.Playwright/TestProject.csproj @@ -8,7 +8,7 @@ - + diff --git a/TUnit.Templates/content/TUnit.VB/TestProject.vbproj b/TUnit.Templates/content/TUnit.VB/TestProject.vbproj index a347213711..305fd42dac 100644 --- a/TUnit.Templates/content/TUnit.VB/TestProject.vbproj +++ b/TUnit.Templates/content/TUnit.VB/TestProject.vbproj @@ -8,6 +8,6 @@ - + diff --git a/TUnit.Templates/content/TUnit/TestProject.csproj b/TUnit.Templates/content/TUnit/TestProject.csproj index e7b61c658b..caec831354 100644 --- a/TUnit.Templates/content/TUnit/TestProject.csproj +++ b/TUnit.Templates/content/TUnit/TestProject.csproj @@ -8,7 +8,7 @@ - + \ No newline at end of file diff --git a/TUnit.TestProject/AfterTests/TestDiscoveryAfterTests.cs b/TUnit.TestProject/AfterTests/TestDiscoveryAfterTests.cs index b1f80522e1..5d02d5dac8 100644 --- a/TUnit.TestProject/AfterTests/TestDiscoveryAfterTests.cs +++ b/TUnit.TestProject/AfterTests/TestDiscoveryAfterTests.cs @@ -13,10 +13,13 @@ public static async Task AfterEveryTestDiscovery(TestDiscoveryContext context) { await FilePolyfill.WriteAllTextAsync($"TestDiscoveryAfterTests{Guid.NewGuid():N}.txt", $"{context.AllTests.Count()} tests found"); - var test = context.AllTests.First(x => + var test = context.AllTests.FirstOrDefault(x => x.TestDetails.TestName == nameof(TestDiscoveryAfterTests.EnsureAfterEveryTestDiscoveryHit)); - test.ObjectBag.Add("AfterEveryTestDiscoveryHit", true); + if (test is not null) + { + test.ObjectBag.Add("AfterEveryTestDiscoveryHit", true); + } } } diff --git a/global.json b/global.json index 08c5374eff..095c4095fd 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "9.0.305", + "version": "9.0.306", "rollForward": "latestMajor", "allowPrerelease": true },