diff --git a/TUnit.Engine.Tests/PropertyInjectionInitFailureTests.cs b/TUnit.Engine.Tests/PropertyInjectionInitFailureTests.cs new file mode 100644 index 0000000000..d2e14a00f8 --- /dev/null +++ b/TUnit.Engine.Tests/PropertyInjectionInitFailureTests.cs @@ -0,0 +1,21 @@ +using Shouldly; +using TUnit.Engine.Tests.Enums; + +namespace TUnit.Engine.Tests; + +public class PropertyInjectionInitFailureTests(TestMode testMode) : InvokableTestBase(testMode) +{ + [Test] + public async Task Test() + { + await RunTestsWithFilter( + "/*/*/PropertyInjectionInitFailureTests/*", + [ + result => result.ResultSummary.Outcome.ShouldBe("Failed"), + result => result.ResultSummary.Counters.Total.ShouldBe(1), + result => result.ResultSummary.Counters.Passed.ShouldBe(0), + result => result.ResultSummary.Counters.Failed.ShouldBe(1), + result => result.ResultSummary.Counters.NotExecuted.ShouldBe(0) + ]); + } +} diff --git a/TUnit.Engine/Building/Interfaces/ITestBuilder.cs b/TUnit.Engine/Building/Interfaces/ITestBuilder.cs index 018c6c061e..24aeb2921b 100644 --- a/TUnit.Engine/Building/Interfaces/ITestBuilder.cs +++ b/TUnit.Engine/Building/Interfaces/ITestBuilder.cs @@ -16,7 +16,7 @@ internal interface ITestBuilder /// /// Whether this test is reusing the discovery instance /// An executable test ready for execution - Task BuildTestAsync(TestMetadata metadata, TestBuilder.TestData testData, TestBuilderContext testBuilderContext, bool isReusingDiscoveryInstance = false); + Task BuildTestAsync(TestMetadata metadata, TestBuilder.TestData testData, TestBuilderContext testBuilderContext, bool isReusingDiscoveryInstance = false, CancellationToken cancellationToken = default); /// /// Builds all executable tests from a single TestMetadata using its DataCombinationGenerator delegate. @@ -28,7 +28,7 @@ internal interface ITestBuilder #if NET6_0_OR_GREATER [RequiresUnreferencedCode("Test building in reflection mode uses generic type resolution which requires unreferenced code")] #endif - Task> BuildTestsFromMetadataAsync(TestMetadata metadata, TestBuildingContext buildingContext); + Task> BuildTestsFromMetadataAsync(TestMetadata metadata, TestBuildingContext buildingContext, CancellationToken cancellationToken = default); /// /// 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 509d0757f2..7d2d0d22ea 100644 --- a/TUnit.Engine/Building/TestBuilder.cs +++ b/TUnit.Engine/Building/TestBuilder.cs @@ -117,7 +117,7 @@ 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, TestBuildingContext buildingContext) + public async Task> BuildTestsFromMetadataAsync(TestMetadata metadata, TestBuildingContext buildingContext, CancellationToken cancellationToken = default) { // OPTIMIZATION: Pre-filter in execution mode to skip building tests that cannot match the filter if (buildingContext.IsForExecution && buildingContext.Filter != null) @@ -143,7 +143,7 @@ public async Task> BuildTestsFromMetadataAsy // Build tests from each concrete instantiation foreach (var concreteMetadata in genericMetadata.ConcreteInstantiations.Values) { - var concreteTests = await BuildTestsFromMetadataAsync(concreteMetadata, buildingContext); + var concreteTests = await BuildTestsFromMetadataAsync(concreteMetadata, buildingContext, cancellationToken); tests.AddRange(concreteTests); } return tests; @@ -154,7 +154,7 @@ public async Task> BuildTestsFromMetadataAsy var repeatCount = metadata.RepeatCount ?? 0; // Create and initialize attributes ONCE - var attributes = await InitializeAttributesAsync(metadata.GetOrCreateAttributes()); + var attributes = await InitializeAttributesAsync(metadata.GetOrCreateAttributes(), cancellationToken); if (metadata.ClassDataSources.Any(ds => ds is IAccessesInstanceData)) { @@ -185,7 +185,7 @@ public async Task> BuildTestsFromMetadataAsy var contextAccessor = new TestBuilderContextAccessor(testBuilderContext); var classDataAttributeIndex = 0; - foreach (var classDataSource in await GetDataSourcesAsync(metadata.ClassDataSources)) + foreach (var classDataSource in await GetDataSourcesAsync(metadata.ClassDataSources, cancellationToken)) { classDataAttributeIndex++; @@ -201,7 +201,8 @@ public async Task> BuildTestsFromMetadataAsy testClassInstance: null, // Never pass instance for class data sources (circular dependency) classInstanceArguments: null, contextAccessor - ))) + ), + cancellationToken)) { hasAnyClassData = true; classDataLoopIndex++; @@ -271,7 +272,8 @@ await _objectLifecycleService.RegisterObjectAsync( instanceForMethodDataSources, tempObjectBag, metadata.MethodMetadata, - tempEvents); + tempEvents, + cancellationToken); // Discovery: only IAsyncDiscoveryInitializer is initialized await ObjectInitializer.InitializeForDiscoveryAsync(instanceForMethodDataSources); @@ -286,7 +288,7 @@ await _objectLifecycleService.RegisterObjectAsync( } var methodDataAttributeIndex = 0; - foreach (var methodDataSource in await GetDataSourcesAsync(metadata.DataSources)) + foreach (var methodDataSource in await GetDataSourcesAsync(metadata.DataSources, cancellationToken)) { methodDataAttributeIndex++; @@ -302,7 +304,8 @@ await _objectLifecycleService.RegisterObjectAsync( testClassInstance: methodDataSource is IAccessesInstanceData ? instanceForMethodDataSources : null, classInstanceArguments: classData, contextAccessor - ))) + ), + cancellationToken)) { hasAnyMethodData = true; methodDataLoopIndex++; @@ -453,7 +456,7 @@ await _objectLifecycleService.RegisterObjectAsync( InitializedAttributes = attributes }; - var test = await BuildTestAsync(metadata, testData, testSpecificContext, isReusingDiscoveryInstance); + var test = await BuildTestAsync(metadata, testData, testSpecificContext, isReusingDiscoveryInstance, cancellationToken); // If we have a basic skip reason, set it immediately if (!string.IsNullOrEmpty(basicSkipReason)) @@ -517,7 +520,7 @@ await _objectLifecycleService.RegisterObjectAsync( InitializedAttributes = attributes }; - var test = await BuildTestAsync(metadata, testData, testSpecificContext); + var test = await BuildTestAsync(metadata, testData, testSpecificContext, cancellationToken: cancellationToken); test.Context.SkipReason = skipReason; tests.Add(test); } @@ -576,7 +579,7 @@ await _objectLifecycleService.RegisterObjectAsync( InitializedAttributes = attributes }; - var test = await BuildTestAsync(metadata, testData, testSpecificContext); + var test = await BuildTestAsync(metadata, testData, testSpecificContext, cancellationToken: cancellationToken); test.Context.SkipReason = skipReason; tests.Add(test); } @@ -814,7 +817,7 @@ private static Type[] TryInferClassGenericsFromDataSources(TestMetadata metadata private static readonly IDataSourceAttribute[] _dataSourceArray = [NoDataSource.Instance]; - private async Task GetDataSourcesAsync(IDataSourceAttribute[] dataSources) + private async Task GetDataSourcesAsync(IDataSourceAttribute[] dataSources, CancellationToken cancellationToken = default) { if (dataSources.Length == 0) { @@ -824,7 +827,7 @@ private async Task GetDataSourcesAsync(IDataSourceAttrib // Inject properties into data sources during discovery (IAsyncInitializer deferred to execution) foreach (var dataSource in dataSources) { - await _objectLifecycleService.InjectPropertiesAsync(dataSource); + await _objectLifecycleService.InjectPropertiesAsync(dataSource, cancellationToken: cancellationToken); } return dataSources; @@ -836,14 +839,16 @@ private async Task GetDataSourcesAsync(IDataSourceAttrib /// private async IAsyncEnumerable>> GetInitializedDataRowsAsync( IDataSourceAttribute dataSource, - DataGeneratorMetadata dataGeneratorMetadata) + DataGeneratorMetadata dataGeneratorMetadata, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) { // Inject properties into data source during discovery (IAsyncInitializer deferred to execution) var propertyInjectedDataSource = await _objectLifecycleService.InjectPropertiesAsync( dataSource, dataGeneratorMetadata.TestBuilderContext.Current.StateBag, dataGeneratorMetadata.TestInformation, - dataGeneratorMetadata.TestBuilderContext.Current.Events); + dataGeneratorMetadata.TestBuilderContext.Current.Events, + cancellationToken); // Now get data rows from the property-injected data source await foreach (var dataRow in propertyInjectedDataSource.GetDataRowsAsync(dataGeneratorMetadata)) @@ -854,7 +859,7 @@ private async Task GetDataSourcesAsync(IDataSourceAttrib [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Hook discovery service handles mode-specific logic; reflection calls suppressed in AOT mode")] [UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Hook discovery service handles mode-specific logic; dynamic code suppressed in AOT mode")] - public async Task BuildTestAsync(TestMetadata metadata, TestData testData, TestBuilderContext testBuilderContext, bool isReusingDiscoveryInstance = false) + public async Task BuildTestAsync(TestMetadata metadata, TestData testData, TestBuilderContext testBuilderContext, bool isReusingDiscoveryInstance = false, CancellationToken cancellationToken = default) { // Discover instance hooks for closed generic types (no-op in source gen mode) if (metadata.TestClassType is { IsGenericType: true, IsGenericTypeDefinition: false }) @@ -864,7 +869,7 @@ public async Task BuildTestAsync(TestMetadata metadata, var testId = TestIdentifierService.GenerateTestId(metadata, testData); - var context = await CreateTestContextAsync(testId, metadata, testData, testBuilderContext); + var context = await CreateTestContextAsync(testId, metadata, testData, testBuilderContext, cancellationToken); // Mark if this test is reusing the discovery instance (already initialized) context.IsDiscoveryInstanceReused = isReusingDiscoveryInstance; @@ -923,7 +928,7 @@ public async Task BuildTestAsync(TestMetadata metadata, // in InvokePostResolutionEventsAsync after dependencies are resolved try { - await RegisterTestArgumentsAsync(context); + await RegisterTestArgumentsAsync(context, cancellationToken); } catch (Exception ex) { @@ -1044,10 +1049,10 @@ private static void CollectAllDependencies(AbstractExecutableTest test, HashSet< return firstSkipAttribute?.Reason; } - private async ValueTask CreateTestContextAsync(string testId, TestMetadata metadata, TestData testData, TestBuilderContext testBuilderContext) + private async ValueTask CreateTestContextAsync(string testId, TestMetadata metadata, TestData testData, TestBuilderContext testBuilderContext, CancellationToken cancellationToken = default) { // Use attributes from context if available, or create new ones - var attributes = testBuilderContext.InitializedAttributes ?? await InitializeAttributesAsync(metadata.GetOrCreateAttributes()); + var attributes = testBuilderContext.InitializedAttributes ?? await InitializeAttributesAsync(metadata.GetOrCreateAttributes(), cancellationToken); if (testBuilderContext.DataSourceAttribute != null && testBuilderContext.DataSourceAttribute is not NoDataSource) { @@ -1089,7 +1094,7 @@ private async ValueTask CreateTestContextAsync(string testId, TestM /// Registers test arguments for property injection and reference counting. /// Called during test building, before dependencies are resolved. /// - private async Task RegisterTestArgumentsAsync(TestContext context) + private async Task RegisterTestArgumentsAsync(TestContext context, CancellationToken cancellationToken = default) { var discoveredTest = new DiscoveredTest { @@ -1099,7 +1104,7 @@ private async Task RegisterTestArgumentsAsync(TestContext context) context.InternalDiscoveredTest = discoveredTest; // Invoke the global test argument registration service to register shared instances - await _testArgumentRegistrationService.RegisterTestArgumentsAsync(context); + await _testArgumentRegistrationService.RegisterTestArgumentsAsync(context, cancellationToken); } #if NET6_0_OR_GREATER @@ -1167,7 +1172,7 @@ private TestDetails CreateFailedTestDetails(TestMetadata metadata, string testId }; } - private async Task InitializeAttributesAsync(Attribute[] attributes) + private async Task InitializeAttributesAsync(Attribute[] attributes, CancellationToken cancellationToken = default) { // Inject properties into data source attributes during discovery // IAsyncInitializer.InitializeAsync is deferred to execution time @@ -1175,7 +1180,7 @@ private async Task InitializeAttributesAsync(Attribute[] attributes { if (attribute is IDataSourceAttribute dataSource) { - await _objectLifecycleService.InjectPropertiesAsync(dataSource); + await _objectLifecycleService.InjectPropertiesAsync(dataSource, cancellationToken: cancellationToken); } } @@ -1533,7 +1538,7 @@ public async IAsyncEnumerable BuildTestsStreamingAsync( var repeatCount = metadata.RepeatCount ?? 0; // Initialize attributes - var attributes = await InitializeAttributesAsync(metadata.GetOrCreateAttributes()); + var attributes = await InitializeAttributesAsync(metadata.GetOrCreateAttributes(), cancellationToken); // Create base context with ClassConstructor if present // StateBag and Events are lazy-initialized for performance @@ -1571,7 +1576,7 @@ public async IAsyncEnumerable BuildTestsStreamingAsync( // Stream through all data source combinations var classDataAttributeIndex = 0; - foreach (var classDataSource in await GetDataSourcesAsync(metadata.ClassDataSources)) + foreach (var classDataSource in await GetDataSourcesAsync(metadata.ClassDataSources, cancellationToken)) { classDataAttributeIndex++; var classDataLoopIndex = 0; @@ -1584,7 +1589,8 @@ public async IAsyncEnumerable BuildTestsStreamingAsync( generatorType: DataGeneratorType.ClassParameters, testClassInstance: null, classInstanceArguments: null, - contextAccessor)).WithCancellation(cancellationToken)) + contextAccessor), + cancellationToken).WithCancellation(cancellationToken)) { cancellationToken.ThrowIfCancellationRequested(); classDataLoopIndex++; @@ -1621,7 +1627,8 @@ await _objectLifecycleService.RegisterObjectAsync( instanceForMethodDataSources, tempObjectBag, metadata.MethodMetadata, - tempEvents); + tempEvents, + cancellationToken); // Discovery: only IAsyncDiscoveryInitializer is initialized await ObjectInitializer.InitializeForDiscoveryAsync(instanceForMethodDataSources); @@ -1629,7 +1636,7 @@ await _objectLifecycleService.RegisterObjectAsync( // Stream through method data sources var methodDataAttributeIndex = 0; - foreach (var methodDataSource in await GetDataSourcesAsync(metadata.DataSources)) + foreach (var methodDataSource in await GetDataSourcesAsync(metadata.DataSources, cancellationToken)) { methodDataAttributeIndex++; var methodDataLoopIndex = 0; @@ -1642,7 +1649,8 @@ await _objectLifecycleService.RegisterObjectAsync( generatorType: DataGeneratorType.TestParameters, testClassInstance: methodDataSource is IAccessesInstanceData ? instanceForMethodDataSources : null, classInstanceArguments: classData, - contextAccessor))) + contextAccessor), + cancellationToken)) { cancellationToken.ThrowIfCancellationRequested(); methodDataLoopIndex++; @@ -1658,7 +1666,8 @@ await _objectLifecycleService.RegisterObjectAsync( classDataAttributeIndex, classDataLoopIndex, methodDataAttributeIndex, methodDataLoopIndex, i, contextAccessor, - classDataSource, methodDataSource); + classDataSource, methodDataSource, + cancellationToken); if (test != null) { @@ -1730,7 +1739,8 @@ private Task CreateInstanceForMethodDataSources( int repeatIndex, TestBuilderContextAccessor contextAccessor, IDataSourceAttribute? classDataSource = null, - IDataSourceAttribute? methodDataSource = null) + IDataSourceAttribute? methodDataSource = null, + CancellationToken cancellationToken = default) { try { @@ -1849,7 +1859,7 @@ private Task CreateInstanceForMethodDataSources( InitializedAttributes = attributes }; - var test = await BuildTestAsync(metadata, testData, testSpecificContext); + var test = await BuildTestAsync(metadata, testData, testSpecificContext, cancellationToken: cancellationToken); if (!string.IsNullOrEmpty(basicSkipReason)) { diff --git a/TUnit.Engine/Building/TestBuilderPipeline.cs b/TUnit.Engine/Building/TestBuilderPipeline.cs index 5a012458bd..2e0b78536a 100644 --- a/TUnit.Engine/Building/TestBuilderPipeline.cs +++ b/TUnit.Engine/Building/TestBuilderPipeline.cs @@ -126,7 +126,7 @@ public async Task> BuildTestsStreamingAsync( } return await collectedMetadata - .SelectManyAsync(metadata => BuildTestsFromSingleMetadataAsync(metadata, buildingContext), cancellationToken: cancellationToken) + .SelectManyAsync(metadata => BuildTestsFromSingleMetadataAsync(metadata, buildingContext, cancellationToken), cancellationToken: cancellationToken) .ProcessInParallel(cancellationToken: cancellationToken); } @@ -170,7 +170,7 @@ public async Task> BuildTestsFromMetadataAsy } else { - results.Add(await _testBuilder.BuildTestsFromMetadataAsync(metadata, buildingContext).ConfigureAwait(false)); + results.Add(await _testBuilder.BuildTestsFromMetadataAsync(metadata, buildingContext, cancellationToken).ConfigureAwait(false)); } } catch (Exception ex) @@ -194,7 +194,7 @@ public async Task> BuildTestsFromMetadataAsy return await GenerateDynamicTests(metadata).ConfigureAwait(false); } - return await _testBuilder.BuildTestsFromMetadataAsync(metadata, buildingContext).ConfigureAwait(false); + return await _testBuilder.BuildTestsFromMetadataAsync(metadata, buildingContext, cancellationToken).ConfigureAwait(false); } catch (Exception ex) { @@ -308,7 +308,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, TestBuildingContext buildingContext) + private async IAsyncEnumerable BuildTestsFromSingleMetadataAsync(TestMetadata metadata, TestBuildingContext buildingContext, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) { TestMetadata resolvedMetadata; Exception? resolutionError = null; @@ -430,7 +430,7 @@ private async IAsyncEnumerable BuildTestsFromSingleMetad else { // Normal test metadata goes through the standard test builder - var testsFromMetadata = await _testBuilder.BuildTestsFromMetadataAsync(resolvedMetadata, buildingContext).ConfigureAwait(false); + var testsFromMetadata = await _testBuilder.BuildTestsFromMetadataAsync(resolvedMetadata, buildingContext, cancellationToken).ConfigureAwait(false); testsToYield = new List(testsFromMetadata); } } diff --git a/TUnit.Engine/Services/IObjectRegistry.cs b/TUnit.Engine/Services/IObjectRegistry.cs index b704bd32fe..84f2585d30 100644 --- a/TUnit.Engine/Services/IObjectRegistry.cs +++ b/TUnit.Engine/Services/IObjectRegistry.cs @@ -22,7 +22,8 @@ Task RegisterObjectAsync( object instance, ConcurrentDictionary objectBag, MethodMetadata? methodMetadata, - TestContextEvents events); + TestContextEvents events, + CancellationToken cancellationToken = default); /// /// Registers multiple argument objects during the registration phase. @@ -31,5 +32,6 @@ Task RegisterArgumentsAsync( object?[] arguments, ConcurrentDictionary objectBag, MethodMetadata? methodMetadata, - TestContextEvents events); + TestContextEvents events, + CancellationToken cancellationToken = default); } diff --git a/TUnit.Engine/Services/ObjectLifecycleService.cs b/TUnit.Engine/Services/ObjectLifecycleService.cs index 1fd081dbe3..359908a8d0 100644 --- a/TUnit.Engine/Services/ObjectLifecycleService.cs +++ b/TUnit.Engine/Services/ObjectLifecycleService.cs @@ -52,7 +52,7 @@ public ObjectLifecycleService( /// Tracks the resolved objects so reference counting works correctly across all tests. /// Does NOT call IAsyncInitializer (deferred to execution). /// - public async Task RegisterTestAsync(TestContext testContext) + public async Task RegisterTestAsync(TestContext testContext, CancellationToken cancellationToken = default) { var objectBag = testContext.StateBag.Items; var methodMetadata = testContext.Metadata.TestDetails.MethodMetadata; @@ -61,7 +61,7 @@ public async Task RegisterTestAsync(TestContext testContext) // Resolve property values (creating shared objects) and cache them WITHOUT setting on placeholder instance // This ensures shared objects are created once and tracked with the correct reference count - await PropertyInjector.ResolveAndCachePropertiesAsync(testClassType, objectBag, methodMetadata, events, testContext); + await PropertyInjector.ResolveAndCachePropertiesAsync(testClassType, objectBag, methodMetadata, events, testContext, cancellationToken); // Track the cached objects so they get the correct reference count _objectTracker.TrackObjects(testContext); @@ -75,7 +75,8 @@ public Task RegisterObjectAsync( object instance, ConcurrentDictionary objectBag, MethodMetadata? methodMetadata, - TestContextEvents events) + TestContextEvents events, + CancellationToken cancellationToken = default) { if (instance == null) { @@ -83,7 +84,7 @@ public Task RegisterObjectAsync( } // Inject properties during registration - return PropertyInjector.InjectPropertiesAsync(instance, objectBag, methodMetadata, events); + return PropertyInjector.InjectPropertiesAsync(instance, objectBag, methodMetadata, events, cancellationToken); } /// @@ -93,7 +94,8 @@ public Task RegisterArgumentsAsync( object?[] arguments, ConcurrentDictionary objectBag, MethodMetadata? methodMetadata, - TestContextEvents events) + TestContextEvents events, + CancellationToken cancellationToken = default) { if (arguments == null || arguments.Length == 0) { @@ -106,7 +108,7 @@ public Task RegisterArgumentsAsync( { if (argument != null) { - tasks.Append(RegisterObjectAsync(argument, objectBag, methodMetadata, events)); + tasks.Append(RegisterObjectAsync(argument, objectBag, methodMetadata, events, cancellationToken)); } } @@ -290,7 +292,8 @@ public async ValueTask InjectPropertiesAsync( T obj, ConcurrentDictionary? objectBag = null, MethodMetadata? methodMetadata = null, - TestContextEvents? events = null) where T : notnull + TestContextEvents? events = null, + CancellationToken cancellationToken = default) where T : notnull { if (obj == null) { @@ -301,7 +304,7 @@ public async ValueTask InjectPropertiesAsync( events ??= new TestContextEvents(); // Only inject properties, do not call IAsyncInitializer - await PropertyInjector.InjectPropertiesAsync(obj, objectBag, methodMetadata, events); + await PropertyInjector.InjectPropertiesAsync(obj, objectBag, methodMetadata, events, cancellationToken); return obj; } @@ -391,7 +394,7 @@ private async Task InitializeObjectCoreAsync( // This aligns with ObjectInitializer behavior and provides cleaner stack traces // Step 1: Inject properties - await PropertyInjector.InjectPropertiesAsync(obj, objectBag, methodMetadata, events); + await PropertyInjector.InjectPropertiesAsync(obj, objectBag, methodMetadata, events, cancellationToken); // Step 2: Initialize nested objects depth-first (discovery-only) await InitializeNestedObjectsForDiscoveryAsync(obj, cancellationToken); diff --git a/TUnit.Engine/Services/PropertyInjector.cs b/TUnit.Engine/Services/PropertyInjector.cs index 7dd08cfe5d..a6492ad786 100644 --- a/TUnit.Engine/Services/PropertyInjector.cs +++ b/TUnit.Engine/Services/PropertyInjector.cs @@ -42,7 +42,8 @@ public Task ResolveAndCachePropertiesAsync( ConcurrentDictionary objectBag, MethodMetadata? methodMetadata, TestContextEvents events, - TestContext testContext) + TestContext testContext, + CancellationToken cancellationToken = default) { // Skip property resolution if this test is reusing the discovery instance (already initialized) if (testContext.IsDiscoveryInstanceReused) @@ -59,25 +60,25 @@ public Task ResolveAndCachePropertiesAsync( if (plan.SourceGeneratedProperties.Length > 0 || plan.ReflectionProperties.Length > 0) { - return ResolveAndCachePropertiesCoreAsync(objectBag, methodMetadata, events, testContext, plan); + return ResolveAndCachePropertiesCoreAsync(objectBag, methodMetadata, events, testContext, plan, cancellationToken); } return Task.CompletedTask; } private async Task ResolveAndCachePropertiesCoreAsync(ConcurrentDictionary objectBag, MethodMetadata? methodMetadata, - TestContextEvents events, TestContext testContext, PropertyInjectionPlan plan) + TestContextEvents events, TestContext testContext, PropertyInjectionPlan plan, CancellationToken cancellationToken) { // Resolve properties based on what's available in the plan if (plan.SourceGeneratedProperties.Length > 0) { await ResolveAndCacheSourceGeneratedPropertiesAsync( - plan.SourceGeneratedProperties, objectBag, methodMetadata, events, testContext); + plan.SourceGeneratedProperties, objectBag, methodMetadata, events, testContext, cancellationToken); } else if (plan.ReflectionProperties.Length > 0) { await ResolveAndCacheReflectionPropertiesAsync( - plan.ReflectionProperties, objectBag, methodMetadata, events, testContext); + plan.ReflectionProperties, objectBag, methodMetadata, events, testContext, cancellationToken); } } @@ -88,7 +89,8 @@ public async Task InjectPropertiesAsync( object instance, ConcurrentDictionary objectBag, MethodMetadata? methodMetadata, - TestContextEvents events) + TestContextEvents events, + CancellationToken cancellationToken = default) { if (instance == null) { @@ -117,7 +119,7 @@ public async Task InjectPropertiesAsync( try { - await InjectPropertiesRecursiveAsync(instance, objectBag, methodMetadata, events, visitedObjects); + await InjectPropertiesRecursiveAsync(instance, objectBag, methodMetadata, events, visitedObjects, cancellationToken); } finally { @@ -133,7 +135,8 @@ public async Task InjectPropertiesIntoArgumentsAsync( object?[] arguments, ConcurrentDictionary objectBag, MethodMetadata methodMetadata, - TestContextEvents events) + TestContextEvents events, + CancellationToken cancellationToken = default) { if (arguments.Length == 0) { @@ -159,7 +162,7 @@ public async Task InjectPropertiesIntoArgumentsAsync( var tasks = new List(injectableArgs.Count); foreach (var arg in injectableArgs) { - tasks.Add(InjectPropertiesAsync(arg, objectBag, methodMetadata, events)); + tasks.Add(InjectPropertiesAsync(arg, objectBag, methodMetadata, events, cancellationToken)); } await Task.WhenAll(tasks); @@ -170,7 +173,8 @@ private async Task InjectPropertiesRecursiveAsync( ConcurrentDictionary objectBag, MethodMetadata? methodMetadata, TestContextEvents events, - ConcurrentDictionary visitedObjects) + ConcurrentDictionary visitedObjects, + CancellationToken cancellationToken) { if (instance == null) { @@ -193,17 +197,17 @@ private async Task InjectPropertiesRecursiveAsync( if (plan.SourceGeneratedProperties.Length > 0) { await InjectSourceGeneratedPropertiesAsync( - instance, plan.SourceGeneratedProperties, objectBag, methodMetadata, events, visitedObjects); + instance, plan.SourceGeneratedProperties, objectBag, methodMetadata, events, visitedObjects, cancellationToken); } else if (plan.ReflectionProperties.Length > 0) { await InjectReflectionPropertiesAsync( - instance, plan.ReflectionProperties, objectBag, methodMetadata, events, visitedObjects); + instance, plan.ReflectionProperties, objectBag, methodMetadata, events, visitedObjects, cancellationToken); } } // Recurse into nested properties - await RecurseIntoNestedPropertiesAsync(instance, plan, objectBag, methodMetadata, events, visitedObjects); + await RecurseIntoNestedPropertiesAsync(instance, plan, objectBag, methodMetadata, events, visitedObjects, cancellationToken); } catch (Exception ex) { @@ -218,10 +222,11 @@ private Task InjectSourceGeneratedPropertiesAsync( ConcurrentDictionary objectBag, MethodMetadata? methodMetadata, TestContextEvents events, - ConcurrentDictionary visitedObjects) + ConcurrentDictionary visitedObjects, + CancellationToken cancellationToken) { return ParallelTaskHelper.ForEachAsync(properties, - prop => InjectSourceGeneratedPropertyAsync(instance, prop, objectBag, methodMetadata, events, visitedObjects)); + prop => InjectSourceGeneratedPropertyAsync(instance, prop, objectBag, methodMetadata, events, visitedObjects, cancellationToken)); } [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Source-gen properties are AOT-safe")] @@ -231,7 +236,8 @@ private async Task InjectSourceGeneratedPropertyAsync( ConcurrentDictionary objectBag, MethodMetadata? methodMetadata, TestContextEvents events, - ConcurrentDictionary visitedObjects) + ConcurrentDictionary visitedObjects, + CancellationToken cancellationToken) { // First check if the property already has a value - skip if it does // This handles nested objects that were already constructed with their properties set @@ -274,7 +280,8 @@ private async Task InjectSourceGeneratedPropertyAsync( VisitedObjects = visitedObjects, TestContext = testContext, IsNestedProperty = false - }); + }, + cancellationToken); if (resolvedValue == null) { @@ -300,10 +307,11 @@ private Task InjectReflectionPropertiesAsync( ConcurrentDictionary objectBag, MethodMetadata? methodMetadata, TestContextEvents events, - ConcurrentDictionary visitedObjects) + ConcurrentDictionary visitedObjects, + CancellationToken cancellationToken) { return ParallelTaskHelper.ForEachAsync(properties, - pair => InjectReflectionPropertyAsync(instance, pair.Property, pair.DataSource, objectBag, methodMetadata, events, visitedObjects)); + pair => InjectReflectionPropertyAsync(instance, pair.Property, pair.DataSource, objectBag, methodMetadata, events, visitedObjects, cancellationToken)); } [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Reflection mode is not used in AOT")] @@ -314,7 +322,8 @@ private async Task InjectReflectionPropertyAsync( ConcurrentDictionary objectBag, MethodMetadata? methodMetadata, TestContextEvents events, - ConcurrentDictionary visitedObjects) + ConcurrentDictionary visitedObjects, + CancellationToken cancellationToken) { var testContext = TestContext.Current; var propertySetter = PropertySetterFactory.CreateSetter(property); @@ -334,7 +343,8 @@ private async Task InjectReflectionPropertyAsync( VisitedObjects = visitedObjects, TestContext = testContext, IsNestedProperty = false - }); + }, + cancellationToken); if (resolvedValue == null) { @@ -350,7 +360,8 @@ private Task RecurseIntoNestedPropertiesAsync( ConcurrentDictionary objectBag, MethodMetadata? methodMetadata, TestContextEvents events, - ConcurrentDictionary visitedObjects) + ConcurrentDictionary visitedObjects, + CancellationToken cancellationToken) { if (!plan.HasProperties) { @@ -359,7 +370,7 @@ private Task RecurseIntoNestedPropertiesAsync( if (plan.SourceGeneratedProperties.Length > 0 || plan.ReflectionProperties.Length > 0) { - return RecurseIntoNestedPropertiesCoreAsync(instance, plan, objectBag, methodMetadata, events, visitedObjects); + return RecurseIntoNestedPropertiesCoreAsync(instance, plan, objectBag, methodMetadata, events, visitedObjects, cancellationToken); } return Task.CompletedTask; @@ -367,7 +378,7 @@ private Task RecurseIntoNestedPropertiesAsync( private async Task RecurseIntoNestedPropertiesCoreAsync(object instance, PropertyInjectionPlan plan, ConcurrentDictionary objectBag, MethodMetadata? methodMetadata, TestContextEvents events, - ConcurrentDictionary visitedObjects) + ConcurrentDictionary visitedObjects, CancellationToken cancellationToken) { if (plan.SourceGeneratedProperties.Length > 0) { @@ -387,7 +398,7 @@ private async Task RecurseIntoNestedPropertiesCoreAsync(object instance, Propert if (PropertyInjectionCache.HasInjectableProperties(propertyValue.GetType())) { - await InjectPropertiesRecursiveAsync(propertyValue, objectBag, methodMetadata, events, visitedObjects); + await InjectPropertiesRecursiveAsync(propertyValue, objectBag, methodMetadata, events, visitedObjects, cancellationToken); } } } @@ -403,7 +414,7 @@ private async Task RecurseIntoNestedPropertiesCoreAsync(object instance, Propert if (PropertyInjectionCache.HasInjectableProperties(propertyValue.GetType())) { - await InjectPropertiesRecursiveAsync(propertyValue, objectBag, methodMetadata, events, visitedObjects); + await InjectPropertiesRecursiveAsync(propertyValue, objectBag, methodMetadata, events, visitedObjects, cancellationToken); } } } @@ -414,10 +425,11 @@ private Task ResolveAndCacheSourceGeneratedPropertiesAsync( ConcurrentDictionary objectBag, MethodMetadata? methodMetadata, TestContextEvents events, - TestContext testContext) + TestContext testContext, + CancellationToken cancellationToken) { return ParallelTaskHelper.ForEachAsync(properties, - prop => ResolveAndCacheSourceGeneratedPropertyAsync(prop, objectBag, methodMetadata, events, testContext)); + prop => ResolveAndCacheSourceGeneratedPropertyAsync(prop, objectBag, methodMetadata, events, testContext, cancellationToken)); } [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Source-gen properties are AOT-safe")] @@ -426,7 +438,8 @@ private async Task ResolveAndCacheSourceGeneratedPropertyAsync( ConcurrentDictionary objectBag, MethodMetadata? methodMetadata, TestContextEvents events, - TestContext testContext) + TestContext testContext, + CancellationToken cancellationToken) { var cacheKey = PropertyCacheKeyGenerator.GetCacheKey(metadata); @@ -451,7 +464,8 @@ private async Task ResolveAndCacheSourceGeneratedPropertyAsync( VisitedObjects = new ConcurrentDictionary(), // Empty dictionary for cycle detection TestContext = testContext, IsNestedProperty = false - }); + }, + cancellationToken); if (resolvedValue != null) { @@ -467,10 +481,11 @@ private Task ResolveAndCacheReflectionPropertiesAsync( ConcurrentDictionary objectBag, MethodMetadata? methodMetadata, TestContextEvents events, - TestContext testContext) + TestContext testContext, + CancellationToken cancellationToken) { return ParallelTaskHelper.ForEachAsync(properties, - pair => ResolveAndCacheReflectionPropertyAsync(pair.Property, pair.DataSource, objectBag, methodMetadata, events, testContext)); + pair => ResolveAndCacheReflectionPropertyAsync(pair.Property, pair.DataSource, objectBag, methodMetadata, events, testContext, cancellationToken)); } [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Reflection mode is not used in AOT")] @@ -480,7 +495,8 @@ private async Task ResolveAndCacheReflectionPropertyAsync( ConcurrentDictionary objectBag, MethodMetadata? methodMetadata, TestContextEvents events, - TestContext testContext) + TestContext testContext, + CancellationToken cancellationToken) { var cacheKey = PropertyCacheKeyGenerator.GetCacheKey(property); @@ -507,7 +523,8 @@ private async Task ResolveAndCacheReflectionPropertyAsync( VisitedObjects = new ConcurrentDictionary(), // Empty dictionary for cycle detection TestContext = testContext, IsNestedProperty = false - }); + }, + cancellationToken); if (resolvedValue != null) { @@ -522,9 +539,9 @@ private async Task ResolveAndCacheReflectionPropertyAsync( /// [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Property data resolution handles both modes")] [UnconditionalSuppressMessage("Trimming", "IL2072", Justification = "PropertyType is properly preserved through source generation")] - private async Task ResolvePropertyDataAsync(PropertyInitializationContext context) + private async Task ResolvePropertyDataAsync(PropertyInitializationContext context, CancellationToken cancellationToken = default) { - var dataSource = await GetInitializedDataSourceAsync(context); + var dataSource = await GetInitializedDataSourceAsync(context, cancellationToken); if (dataSource == null) { return null; @@ -551,7 +568,8 @@ await _initializationCallback.Value.EnsureInitializedAsync( value, context.ObjectBag, context.MethodMetadata, - context.Events); + context.Events, + cancellationToken); return value; } @@ -560,7 +578,7 @@ await _initializationCallback.Value.EnsureInitializedAsync( return null; } - private async Task GetInitializedDataSourceAsync(PropertyInitializationContext context) + private async Task GetInitializedDataSourceAsync(PropertyInitializationContext context, CancellationToken cancellationToken = default) { IDataSourceAttribute? dataSource = null; @@ -583,7 +601,8 @@ await _initializationCallback.Value.EnsureInitializedAsync( dataSource, context.ObjectBag, context.MethodMetadata, - context.Events); + context.Events, + cancellationToken); } [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Metadata creation handles both modes")] diff --git a/TUnit.Engine/Services/TestArgumentRegistrationService.cs b/TUnit.Engine/Services/TestArgumentRegistrationService.cs index 407df90e4e..26da90c6b5 100644 --- a/TUnit.Engine/Services/TestArgumentRegistrationService.cs +++ b/TUnit.Engine/Services/TestArgumentRegistrationService.cs @@ -21,7 +21,7 @@ public TestArgumentRegistrationService(ObjectLifecycleService objectLifecycleSer /// for proper reference counting and disposal tracking. /// Property values are resolved lazily during test execution (not during discovery). /// - public async ValueTask RegisterTestArgumentsAsync(TestContext testContext) + public async ValueTask RegisterTestArgumentsAsync(TestContext testContext, CancellationToken cancellationToken = default) { TestContext.Current = testContext; @@ -33,16 +33,18 @@ await _objectLifecycleService.RegisterArgumentsAsync( classArguments, testContext.StateBag.Items, testContext.Metadata.TestDetails.MethodMetadata, - testContext.InternalEvents); + testContext.InternalEvents, + cancellationToken); // Register method arguments await _objectLifecycleService.RegisterArgumentsAsync( methodArguments, testContext.StateBag.Items, testContext.Metadata.TestDetails.MethodMetadata, - testContext.InternalEvents); + testContext.InternalEvents, + cancellationToken); // Register the test for tracking (inject properties and track objects for disposal) - await _objectLifecycleService.RegisterTestAsync(testContext); + await _objectLifecycleService.RegisterTestAsync(testContext, cancellationToken); } } diff --git a/TUnit.TestProject/Bugs/4715/AttributePropertyInjectionTests.cs b/TUnit.TestProject/Bugs/4715/AttributePropertyInjectionTests.cs new file mode 100644 index 0000000000..a71a1cf716 --- /dev/null +++ b/TUnit.TestProject/Bugs/4715/AttributePropertyInjectionTests.cs @@ -0,0 +1,63 @@ +using TUnit.Core.Interfaces; +using TUnit.TestProject.Attributes; + +namespace TUnit.TestProject.Bugs._4715; + +/// +/// Closest reproduction to issue #4715: A DI data source attribute with +/// [ClassDataSource] property pointing to a factory that has a nested +/// IAsyncDiscoveryInitializer that throws during InitializeAsync. +/// +/// Pattern from the issue: +/// [AspireDataSource] on test class → +/// AspireDataSourceAttribute has [ClassDataSource<TestWebApplicationFactory>] property → +/// TestWebApplicationFactory has [ClassDataSource<TestAppHost>] property → +/// TestAppHost : IAsyncDiscoveryInitializer → InitializeAsync() throws TimeoutException +/// + +public class FailingHost4715Attr : IAsyncDiscoveryInitializer +{ + public Task InitializeAsync() + { + throw new TimeoutException("Simulated: AppHost startup timed out."); + } +} + +public class Factory4715Attr : IAsyncDiscoveryInitializer +{ + [ClassDataSource(Shared = SharedType.PerTestSession)] + public required FailingHost4715Attr AppHost { get; init; } + + public Task InitializeAsync() + { + return Task.CompletedTask; + } +} + +public class TestDiDataSourceAttribute : DependencyInjectionDataSourceAttribute +{ + [ClassDataSource(Shared = SharedType.PerTestSession)] + public Factory4715Attr Factory { get; init; } = default!; + + public override IDisposable CreateScope(DataGeneratorMetadata dataGeneratorMetadata) + { + // This should never be reached because Factory initialization throws + return new MemoryStream(); + } + + public override object? Create(IDisposable scope, Type type) + { + return new object(); + } +} + +[EngineTest(ExpectedResult.Failure)] +[TestDiDataSource] +public class AttributePropertyInjectionFailureTests(object _) +{ + [Test] + public void Test_Should_Fail_Not_Stall() + { + throw new InvalidOperationException("This test should not have executed"); + } +} diff --git a/TUnit.TestProject/Bugs/4715/BackgroundProcessInitTests.cs b/TUnit.TestProject/Bugs/4715/BackgroundProcessInitTests.cs new file mode 100644 index 0000000000..6c95193ed8 --- /dev/null +++ b/TUnit.TestProject/Bugs/4715/BackgroundProcessInitTests.cs @@ -0,0 +1,85 @@ +using TUnit.Core.Interfaces; +using TUnit.TestProject.Attributes; + +namespace TUnit.TestProject.Bugs._4715; + +/// +/// Reproduction for issue #4715: InitializeAsync starts a background process +/// (simulating Aspire DistributedApplication.StartAsync), then throws. +/// The background process keeps running and prevents the process from exiting. +/// + +public class BackgroundProcessHost4715 : IAsyncDiscoveryInitializer, IAsyncDisposable +{ + private CancellationTokenSource? _cts; + private Task? _backgroundTask; + + public async Task InitializeAsync() + { + // Simulate starting a background process (like Aspire's Application.StartAsync()) + _cts = new CancellationTokenSource(); + _backgroundTask = Task.Run(async () => + { + try + { + // Simulate a long-running service + await Task.Delay(Timeout.Infinite, _cts.Token); + } + catch (OperationCanceledException) + { + // Expected when disposed + } + }, _cts.Token); + + // Give the background task time to start + await Task.Delay(10); + + // Now throw (simulating WaitForResourceAsync timeout) + throw new TimeoutException("The operation has timed out waiting for resource."); + } + + public async ValueTask DisposeAsync() + { + if (_cts != null) + { + await _cts.CancelAsync(); + _cts.Dispose(); + } + + if (_backgroundTask != null) + { + try + { + await _backgroundTask; + } + catch (OperationCanceledException) + { + // Expected + } + } + } +} + +public class BackgroundFactory4715 : IAsyncDiscoveryInitializer +{ + [ClassDataSource(Shared = SharedType.PerTestSession)] + public required BackgroundProcessHost4715 AppHost { get; init; } + + public Task InitializeAsync() + { + return Task.CompletedTask; + } +} + +[EngineTest(ExpectedResult.Failure)] +public class BackgroundProcessInitFailureTests +{ + [ClassDataSource(Shared = SharedType.PerTestSession)] + public required BackgroundFactory4715 Factory { get; init; } + + [Test] + public void Test_Should_Fail_Not_Stall() + { + throw new InvalidOperationException("This test should not have executed"); + } +} diff --git a/TUnit.TestProject/Bugs/4715/PropertyInjectionInitFailureTests.cs b/TUnit.TestProject/Bugs/4715/PropertyInjectionInitFailureTests.cs new file mode 100644 index 0000000000..930df8dda0 --- /dev/null +++ b/TUnit.TestProject/Bugs/4715/PropertyInjectionInitFailureTests.cs @@ -0,0 +1,106 @@ +using TUnit.Core.Interfaces; +using TUnit.TestProject.Attributes; + +namespace TUnit.TestProject.Bugs._4715; + +/// +/// Reproduction for issue #4715: Test runner stalls when IAsyncInitializer.InitializeAsync() +/// throws an exception during property injection in a nested dependency chain. +/// Pattern: Test class → property (WebApplicationFactory) → nested property (AppHost) → InitializeAsync throws +/// + +// Scenario 1: Nested property with IAsyncInitializer that throws during execution +public class FailingAppHost4715 : IAsyncInitializer +{ + public Task InitializeAsync() + { + throw new TimeoutException("Simulated: The operation has timed out (e.g., Docker container failed to start)."); + } +} + +public class WebApplicationFactory4715 : IAsyncInitializer +{ + [ClassDataSource(Shared = SharedType.PerTestSession)] + public required FailingAppHost4715 AppHost { get; init; } + + public Task InitializeAsync() + { + // This should never be reached because AppHost.InitializeAsync() throws first + return Task.CompletedTask; + } +} + +[EngineTest(ExpectedResult.Failure)] +public class PropertyInjectionInitFailureTests +{ + [ClassDataSource(Shared = SharedType.PerTestSession)] + public required WebApplicationFactory4715 Factory { get; init; } + + [Test] + public void Test_Should_Fail_Not_Stall() + { + // This test should never execute - it should fail during initialization + // because the nested AppHost.InitializeAsync() throws TimeoutException + throw new InvalidOperationException("This test should not have executed"); + } +} + +// Scenario 2: Same as above but with IAsyncDiscoveryInitializer (initialized during discovery phase) +public class FailingDiscoveryAppHost4715 : IAsyncDiscoveryInitializer +{ + public Task InitializeAsync() + { + throw new TimeoutException("Simulated discovery init failure: The operation has timed out."); + } +} + +public class DiscoveryWebApplicationFactory4715 : IAsyncDiscoveryInitializer +{ + [ClassDataSource(Shared = SharedType.PerTestSession)] + public required FailingDiscoveryAppHost4715 AppHost { get; init; } + + public Task InitializeAsync() + { + return Task.CompletedTask; + } +} + +[EngineTest(ExpectedResult.Failure)] +public class PropertyInjectionDiscoveryInitFailureTests +{ + [ClassDataSource(Shared = SharedType.PerTestSession)] + public required DiscoveryWebApplicationFactory4715 Factory { get; init; } + + [Test] + public void Test_Should_Fail_Not_Stall_During_Discovery() + { + throw new InvalidOperationException("This test should not have executed"); + } +} + +// Scenario 3: Multiple tests sharing the same failing factory (tests if the shared object error is handled for all) +[EngineTest(ExpectedResult.Failure)] +public class PropertyInjectionInitFailureMultiTest1 +{ + [ClassDataSource(Shared = SharedType.PerTestSession)] + public required WebApplicationFactory4715 Factory { get; init; } + + [Test] + public void Test1_Should_Fail_Not_Stall() + { + throw new InvalidOperationException("This test should not have executed"); + } +} + +[EngineTest(ExpectedResult.Failure)] +public class PropertyInjectionInitFailureMultiTest2 +{ + [ClassDataSource(Shared = SharedType.PerTestSession)] + public required WebApplicationFactory4715 Factory { get; init; } + + [Test] + public void Test2_Should_Fail_Not_Stall() + { + throw new InvalidOperationException("This test should not have executed"); + } +} diff --git a/TUnit.TestProject/Bugs/4715/PropertyInjectionSlowInitTests.cs b/TUnit.TestProject/Bugs/4715/PropertyInjectionSlowInitTests.cs new file mode 100644 index 0000000000..821bb545df --- /dev/null +++ b/TUnit.TestProject/Bugs/4715/PropertyInjectionSlowInitTests.cs @@ -0,0 +1,49 @@ +using TUnit.Core.Interfaces; +using TUnit.TestProject.Attributes; + +namespace TUnit.TestProject.Bugs._4715; + +/// +/// Reproduction for issue #4715: Test runner stalls when IAsyncInitializer.InitializeAsync() +/// hangs for a long time before eventually timing out. +/// This simulates a Docker container or web server that takes too long to start. +/// + +public class SlowFailingAppHost4715 : IAsyncInitializer +{ + public async Task InitializeAsync() + { + // Simulate a slow initialization that eventually times out + // In the real scenario, this might be waiting for a Docker container to start + await Task.Delay(TimeSpan.FromSeconds(30)); + throw new TimeoutException("The operation has timed out after waiting for container."); + } +} + +public class SlowWebApplicationFactory4715 : IAsyncInitializer +{ + [ClassDataSource(Shared = SharedType.PerTestSession)] + public required SlowFailingAppHost4715 AppHost { get; init; } + + public Task InitializeAsync() + { + return Task.CompletedTask; + } +} + +/// +/// This test should fail within a reasonable time, not stall indefinitely. +/// The 30-second delay in InitializeAsync simulates a slow Docker container startup. +/// +[EngineTest(ExpectedResult.Failure)] +public class PropertyInjectionSlowInitFailureTests +{ + [ClassDataSource(Shared = SharedType.PerTestSession)] + public required SlowWebApplicationFactory4715 Factory { get; init; } + + [Test] + public void Test_Should_Eventually_Fail() + { + throw new InvalidOperationException("This test should not have executed"); + } +}