diff --git a/TUnit.Core/DataGeneratorMetadataCreator.cs b/TUnit.Core/DataGeneratorMetadataCreator.cs index cc0b713f70..8abfb23152 100644 --- a/TUnit.Core/DataGeneratorMetadataCreator.cs +++ b/TUnit.Core/DataGeneratorMetadataCreator.cs @@ -170,6 +170,7 @@ public static DataGeneratorMetadata CreateForPropertyInjection( PropertyMetadata propertyMetadata, MethodMetadata? methodMetadata, IDataSourceAttribute dataSource, + string testSessionId, TestContext? testContext = null, object? testClassInstance = null, TestContextEvents? events = null, @@ -193,7 +194,7 @@ public static DataGeneratorMetadata CreateForPropertyInjection( MembersToGenerate = [propertyMetadata], TestInformation = methodMetadata, Type = DataGeneratorType.Property, - TestSessionId = TestSessionContext.Current?.Id ?? "property-injection", + TestSessionId = testSessionId, TestClassInstance = testClassInstance ?? testContext?.Metadata.TestDetails.ClassInstance, ClassInstanceArguments = testContext?.Metadata.TestDetails.TestClassArguments ?? [] }; @@ -215,6 +216,7 @@ public static DataGeneratorMetadata CreateForPropertyInjection( Type containingType, MethodMetadata? methodMetadata, IDataSourceAttribute dataSource, + string testSessionId, TestContext? testContext = null, object? testClassInstance = null, TestContextEvents? events = null, @@ -235,6 +237,7 @@ public static DataGeneratorMetadata CreateForPropertyInjection( propertyMetadata, methodMetadata, dataSource, + testSessionId, testContext, testClassInstance, events, diff --git a/TUnit.Core/Helpers/DataSourceHelpers.cs b/TUnit.Core/Helpers/DataSourceHelpers.cs index d77e6aff47..001fb6077b 100644 --- a/TUnit.Core/Helpers/DataSourceHelpers.cs +++ b/TUnit.Core/Helpers/DataSourceHelpers.cs @@ -561,6 +561,7 @@ public static void RegisterTypeCreator(Func> containingType, testInformation, dataSourceAttribute, + testSessionId, TestContext.Current, TestContext.Current?.Metadata.TestDetails.ClassInstance, TestContext.Current?.InternalEvents, diff --git a/TUnit.Core/Interfaces/SourceGenerator/PropertyInjectionMetadata.cs b/TUnit.Core/Interfaces/SourceGenerator/PropertyInjectionMetadata.cs index d4c8309fc3..bea4f11a9f 100644 --- a/TUnit.Core/Interfaces/SourceGenerator/PropertyInjectionMetadata.cs +++ b/TUnit.Core/Interfaces/SourceGenerator/PropertyInjectionMetadata.cs @@ -24,7 +24,7 @@ public sealed class PropertyInjectionMetadata /// /// Gets the type that contains the property (the parent class). /// - [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.NonPublicProperties)] public required Type ContainingType { get; init; } /// diff --git a/TUnit.Core/PropertyInjection/PropertyHelper.cs b/TUnit.Core/PropertyInjection/PropertyHelper.cs index e5cd934d66..a32d24a7a7 100644 --- a/TUnit.Core/PropertyInjection/PropertyHelper.cs +++ b/TUnit.Core/PropertyInjection/PropertyHelper.cs @@ -11,13 +11,18 @@ internal static class PropertyHelper { /// /// Gets PropertyInfo in an AOT-safe manner. + /// Searches for both public and non-public properties to support internal properties. /// public static PropertyInfo GetPropertyInfo( - [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.NonPublicProperties)] Type containingType, string propertyName) { - var property = containingType.GetProperty(propertyName); + // Use binding flags to find both public and non-public properties + // This is necessary to support internal properties on internal classes + var property = containingType.GetProperty( + propertyName, + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static); if (property == null) { diff --git a/TUnit.Core/PropertyInjection/PropertyInjectionCache.cs b/TUnit.Core/PropertyInjection/PropertyInjectionCache.cs index c883dfc858..b94917c014 100644 --- a/TUnit.Core/PropertyInjection/PropertyInjectionCache.cs +++ b/TUnit.Core/PropertyInjection/PropertyInjectionCache.cs @@ -1,24 +1,22 @@ -using System.Diagnostics.CodeAnalysis; -using TUnit.Core.Data; +using TUnit.Core.Data; namespace TUnit.Core.PropertyInjection; /// -/// Provides caching functionality for property injection operations. -/// Follows Single Responsibility Principle by focusing only on caching. +/// Provides pure caching functionality for property injection metadata. +/// Follows Single Responsibility Principle - only caches type metadata, no execution logic. /// /// This cache supports both execution modes: /// - Source Generation Mode: Uses pre-compiled property setters and metadata /// - Reflection Mode: Uses runtime discovery and dynamic property access /// -/// The IL2067 suppressions are necessary because types come from runtime objects -/// (via GetType() calls) which cannot have compile-time annotations. +/// Instance-level injection tracking has been moved to ObjectLifecycleService +/// to maintain SRP (caching vs execution are separate concerns). /// internal static class PropertyInjectionCache { private static readonly ThreadSafeDictionary _injectionPlans = new(); private static readonly ThreadSafeDictionary _shouldInjectCache = new(); - private static readonly ThreadSafeDictionary> _injectionTasks = new(); /// /// Gets or creates an injection plan for the specified type. @@ -41,42 +39,4 @@ public static bool HasInjectableProperties(Type type) return plan.HasProperties; }); } - - /// - /// Ensures properties are injected into the specified instance. - /// Fast-path optimized for already-injected instances (zero allocation). - /// - public static async ValueTask EnsureInjectedAsync(object instance, Func injectionFactory) - { - if (_injectionTasks.TryGetValue(instance, out var existingTcs) && existingTcs.Task.IsCompleted) - { - if (existingTcs.Task.IsFaulted) - { - await existingTcs.Task.ConfigureAwait(false); - } - - return; - } - - var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - existingTcs = _injectionTasks.GetOrAdd(instance, _ => tcs); - - if (existingTcs == tcs) - { - try - { - await injectionFactory(instance).ConfigureAwait(false); - tcs.SetResult(true); - } - catch (Exception ex) - { - tcs.SetException(ex); - throw; - } - } - else - { - await existingTcs.Task.ConfigureAwait(false); - } - } } diff --git a/TUnit.Engine/Building/TestBuilder.cs b/TUnit.Engine/Building/TestBuilder.cs index 35993d71b6..38cf785996 100644 --- a/TUnit.Engine/Building/TestBuilder.cs +++ b/TUnit.Engine/Building/TestBuilder.cs @@ -21,8 +21,7 @@ internal sealed class TestBuilder : ITestBuilder private readonly string _sessionId; private readonly EventReceiverOrchestrator _eventReceiverOrchestrator; private readonly IContextProvider _contextProvider; - private readonly PropertyInjectionService _propertyInjectionService; - private readonly DataSourceInitializer _dataSourceInitializer; + private readonly ObjectLifecycleService _objectLifecycleService; private readonly Discovery.IHookDiscoveryService _hookDiscoveryService; private readonly TestArgumentRegistrationService _testArgumentRegistrationService; private readonly IMetadataFilterMatcher _filterMatcher; @@ -31,8 +30,7 @@ public TestBuilder( string sessionId, EventReceiverOrchestrator eventReceiverOrchestrator, IContextProvider contextProvider, - PropertyInjectionService propertyInjectionService, - DataSourceInitializer dataSourceInitializer, + ObjectLifecycleService objectLifecycleService, Discovery.IHookDiscoveryService hookDiscoveryService, TestArgumentRegistrationService testArgumentRegistrationService, IMetadataFilterMatcher filterMatcher) @@ -41,8 +39,7 @@ public TestBuilder( _hookDiscoveryService = hookDiscoveryService; _eventReceiverOrchestrator = eventReceiverOrchestrator; _contextProvider = contextProvider; - _propertyInjectionService = propertyInjectionService; - _dataSourceInitializer = dataSourceInitializer; + _objectLifecycleService = objectLifecycleService; _testArgumentRegistrationService = testArgumentRegistrationService; _filterMatcher = filterMatcher ?? throw new ArgumentNullException(nameof(filterMatcher)); } @@ -262,7 +259,7 @@ public async Task> BuildTestsFromMetadataAsy var tempObjectBag = new ConcurrentDictionary(); var tempEvents = new TestContextEvents(); - await _propertyInjectionService.InjectPropertiesIntoObjectAsync( + await _objectLifecycleService.RegisterObjectAsync( instanceForMethodDataSources, tempObjectBag, metadata.MethodMetadata, @@ -772,7 +769,7 @@ private async Task GetDataSourcesAsync(IDataSourceAttrib // Initialize all data sources to ensure properties are injected foreach (var dataSource in dataSources) { - await _dataSourceInitializer.EnsureInitializedAsync(dataSource); + await _objectLifecycleService.EnsureInitializedAsync(dataSource); } return dataSources; @@ -788,7 +785,7 @@ private async Task GetDataSourcesAsync(IDataSourceAttrib { // Ensure the data source is fully initialized before getting data rows // This includes property injection and IAsyncInitializer.InitializeAsync - var initializedDataSource = await _dataSourceInitializer.EnsureInitializedAsync( + var initializedDataSource = await _objectLifecycleService.EnsureInitializedAsync( dataSource, dataGeneratorMetadata.TestBuilderContext.Current.StateBag, dataGeneratorMetadata.TestInformation, @@ -952,7 +949,7 @@ private async Task InvokeTestRegisteredEventReceiversAsync(TestContext context) context.InternalDiscoveredTest = discoveredTest; // First, invoke the global test argument registration service to register shared instances - await _testArgumentRegistrationService.OnTestRegistered(registeredContext); + await _testArgumentRegistrationService.RegisterTestArgumentsAsync(context); var eventObjects = context.GetEligibleEventObjects(); @@ -1027,7 +1024,7 @@ private async Task InitializeAttributesAsync(Attribute[] attributes if (attribute is IDataSourceAttribute dataSource) { // Data source attributes need to be initialized with property injection - await _dataSourceInitializer.EnsureInitializedAsync(dataSource); + await _objectLifecycleService.EnsureInitializedAsync(dataSource); } } @@ -1395,7 +1392,7 @@ public async IAsyncEnumerable BuildTestsStreamingAsync( var tempObjectBag = new ConcurrentDictionary(); var tempEvents = new TestContextEvents(); - await _propertyInjectionService.InjectPropertiesIntoObjectAsync( + await _objectLifecycleService.RegisterObjectAsync( instanceForMethodDataSources, tempObjectBag, metadata.MethodMetadata, diff --git a/TUnit.Engine/Framework/TUnitServiceProvider.cs b/TUnit.Engine/Framework/TUnitServiceProvider.cs index ed2543909d..f64d64d004 100644 --- a/TUnit.Engine/Framework/TUnitServiceProvider.cs +++ b/TUnit.Engine/Framework/TUnitServiceProvider.cs @@ -51,9 +51,7 @@ public ITestExecutionFilter? Filter public TUnitInitializer Initializer { get; } public CancellationTokenSource FailFastCancellationSource { get; } public ParallelLimitLockProvider ParallelLimitLockProvider { get; } - public PropertyInjectionService PropertyInjectionService { get; } - public DataSourceInitializer DataSourceInitializer { get; } - public ObjectRegistrationService ObjectRegistrationService { get; } + public ObjectLifecycleService ObjectLifecycleService { get; } public bool AfterSessionHooksFailed { get; set; } [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Reflection mode is not used in AOT/trimmed scenarios")] @@ -104,25 +102,27 @@ public TUnitServiceProvider(IExtension extension, loggerFactory.CreateLogger(), logLevelProvider)); - // Create initialization services - // Note: Circular dependency managed through two-phase initialization - // Phase 1: Create services with partial dependencies - DataSourceInitializer = Register(new DataSourceInitializer()); - PropertyInjectionService = Register(new PropertyInjectionService(DataSourceInitializer)); - ObjectRegistrationService = Register(new ObjectRegistrationService(PropertyInjectionService)); - - // Phase 2: Complete dependencies (Initialize methods accept IObjectRegistry to break circular dependency) - PropertyInjectionService.Initialize(ObjectRegistrationService); - DataSourceInitializer.Initialize(PropertyInjectionService); + // Create initialization services using Lazy to break circular dependencies + // No more two-phase initialization with Initialize() calls + var objectGraphDiscoveryService = Register(new ObjectGraphDiscoveryService()); + // Keep TrackableObjectGraphProvider for ObjectTracker (TUnit.Core dependency) var trackableObjectGraphProvider = new TrackableObjectGraphProvider(); var disposer = new Disposer(Logger); var objectTracker = new ObjectTracker(trackableObjectGraphProvider, disposer); + // Use Lazy to break circular dependency between PropertyInjector and ObjectLifecycleService + ObjectLifecycleService? objectLifecycleServiceInstance = null; + var lazyObjectLifecycleService = new Lazy(() => objectLifecycleServiceInstance!); + var lazyPropertyInjector = new Lazy(() => new PropertyInjector(lazyObjectLifecycleService, TestSessionId)); + + objectLifecycleServiceInstance = new ObjectLifecycleService(lazyPropertyInjector, objectGraphDiscoveryService, objectTracker); + ObjectLifecycleService = Register(objectLifecycleServiceInstance); + // Register the test argument registration service to handle object registration for shared instances - var testArgumentRegistrationService = Register(new TestArgumentRegistrationService(ObjectRegistrationService, objectTracker)); + var testArgumentRegistrationService = Register(new TestArgumentRegistrationService(ObjectLifecycleService)); TestFilterService = Register(new TestFilterService(Logger, testArgumentRegistrationService)); @@ -135,7 +135,7 @@ public TUnitServiceProvider(IExtension extension, CancellationToken = Register(new EngineCancellationToken()); - EventReceiverOrchestrator = Register(new EventReceiverOrchestrator(Logger, trackableObjectGraphProvider)); + EventReceiverOrchestrator = Register(new EventReceiverOrchestrator(Logger)); HookCollectionService = Register(new HookCollectionService(EventReceiverOrchestrator)); ParallelLimitLockProvider = Register(new ParallelLimitLockProvider()); @@ -174,7 +174,7 @@ public TUnitServiceProvider(IExtension extension, var dependencyExpander = Register(new MetadataDependencyExpander(filterMatcher)); var testBuilder = Register( - new TestBuilder(TestSessionId, EventReceiverOrchestrator, ContextProvider, PropertyInjectionService, DataSourceInitializer, hookDiscoveryService, testArgumentRegistrationService, filterMatcher)); + new TestBuilder(TestSessionId, EventReceiverOrchestrator, ContextProvider, ObjectLifecycleService, hookDiscoveryService, testArgumentRegistrationService, filterMatcher)); TestBuilderPipeline = Register( new TestBuilderPipeline( @@ -188,7 +188,7 @@ public TUnitServiceProvider(IExtension extension, // Create test finder service after discovery service so it can use its cache TestFinder = Register(new TestFinder(DiscoveryService)); - var testInitializer = new TestInitializer(EventReceiverOrchestrator, PropertyInjectionService, DataSourceInitializer, objectTracker); + var testInitializer = new TestInitializer(EventReceiverOrchestrator, ObjectLifecycleService); // Create the new TestCoordinator that orchestrates the granular services var testCoordinator = Register( diff --git a/TUnit.Engine/Services/DataSourceInitializer.cs b/TUnit.Engine/Services/DataSourceInitializer.cs deleted file mode 100644 index e69b0a385f..0000000000 --- a/TUnit.Engine/Services/DataSourceInitializer.cs +++ /dev/null @@ -1,243 +0,0 @@ -using System.Collections.Concurrent; -using TUnit.Core; -using TUnit.Core.Interfaces; -using TUnit.Core.PropertyInjection; - -namespace TUnit.Engine.Services; - -/// -/// Centralized service responsible for initializing data source instances. -/// Ensures all data sources are properly initialized before use, regardless of where they're used -/// (properties, constructor arguments, or method arguments). -/// -internal sealed class DataSourceInitializer -{ - private readonly ConcurrentDictionary> _initializationTasks = new(); - private PropertyInjectionService? _propertyInjectionService; - - /// - /// Completes initialization by providing the PropertyInjectionService. - /// This two-phase initialization breaks the circular dependency. - /// - public void Initialize(PropertyInjectionService propertyInjectionService) - { - _propertyInjectionService = propertyInjectionService ?? throw new ArgumentNullException(nameof(propertyInjectionService)); - } - - /// - /// Ensures a data source instance is fully initialized before use. - /// This includes property injection and calling IAsyncInitializer if implemented. - /// Optimized with fast-path for already-initialized data sources. - /// - public async ValueTask EnsureInitializedAsync( - T dataSource, - ConcurrentDictionary? objectBag = null, - MethodMetadata? methodMetadata = null, - TestContextEvents? events = null, - CancellationToken cancellationToken = default) where T : notnull - { - if (dataSource == null) - { - throw new ArgumentNullException(nameof(dataSource)); - } - - // Fast path: Check if already initialized (avoids Task allocation) - if (_initializationTasks.TryGetValue(dataSource, out var existingTcs) && existingTcs.Task.IsCompleted) - { - // Already initialized - return synchronously without allocating a Task - if (existingTcs.Task.IsFaulted) - { - // Re-throw the original exception - await existingTcs.Task.ConfigureAwait(false); - } - - return dataSource; - } - - // Slow path: Need to initialize or wait for initialization - var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - existingTcs = _initializationTasks.GetOrAdd(dataSource, tcs); - - if (existingTcs == tcs) - { - try - { - await InitializeDataSourceAsync(dataSource, objectBag, methodMetadata, events, cancellationToken).ConfigureAwait(false); - tcs.SetResult(true); - } - catch (Exception ex) - { - tcs.SetException(ex); - throw; - } - } - else - { - // Another thread is initializing or already initialized - wait for it - await existingTcs.Task.ConfigureAwait(false); - - // Check cancellation after waiting - if (cancellationToken.CanBeCanceled) - { - cancellationToken.ThrowIfCancellationRequested(); - } - } - - return dataSource; - } - - /// - /// Initializes a data source instance with the complete lifecycle. - /// - private async Task InitializeDataSourceAsync( - object dataSource, - ConcurrentDictionary? objectBag, - MethodMetadata? methodMetadata, - TestContextEvents? events, - CancellationToken cancellationToken) - { - try - { - // Ensure we have required context - objectBag ??= new ConcurrentDictionary(); - events ??= new TestContextEvents(); - - // Initialize the data source directly here - // Step 1: Property injection (if PropertyInjectionService has been initialized) - if (_propertyInjectionService != null && PropertyInjectionCache.HasInjectableProperties(dataSource.GetType())) - { - await _propertyInjectionService.InjectPropertiesIntoObjectAsync( - dataSource, objectBag, methodMetadata, events); - } - - // Step 2: Initialize nested property-injected objects (deepest first) - // This ensures that when the parent's IAsyncInitializer runs, all nested objects are already initialized - await InitializeNestedObjectsAsync(dataSource, cancellationToken); - - // Step 3: IAsyncInitializer on the data source itself - if (dataSource is IAsyncInitializer asyncInitializer) - { - await ObjectInitializer.InitializeAsync(asyncInitializer, cancellationToken); - } - } - catch (Exception ex) - { - throw new InvalidOperationException( - $"Failed to initialize data source of type '{dataSource.GetType().Name}': {ex.Message}", ex); - } - } - - /// - /// Initializes all nested property-injected objects in depth-first order. - /// This ensures that when the parent's IAsyncInitializer runs, all nested dependencies are already initialized. - /// - private async Task InitializeNestedObjectsAsync(object rootObject, CancellationToken cancellationToken) - { - var objectsByDepth = new Dictionary>(capacity: 4); - var visitedObjects = new HashSet(); - - // Collect all nested property-injected objects grouped by depth - CollectNestedObjects(rootObject, objectsByDepth, visitedObjects, currentDepth: 1); - - // Initialize objects deepest-first (highest depth to lowest) - var depths = objectsByDepth.Keys.OrderByDescending(depth => depth); - - foreach (var depth in depths) - { - var objectsAtDepth = objectsByDepth[depth]; - - // Initialize all objects at this depth in parallel - await Task.WhenAll(objectsAtDepth.Select(obj => ObjectInitializer.InitializeAsync(obj, cancellationToken).AsTask())); - } - } - - /// - /// Recursively collects all nested property-injected objects grouped by depth. - /// - private void CollectNestedObjects( - object obj, - Dictionary> objectsByDepth, - HashSet visitedObjects, - int currentDepth) - { - var plan = PropertyInjectionCache.GetOrCreatePlan(obj.GetType()); - - // Use whichever properties are available in the plan - // For closed generic types, source-gen may not have registered them, so use reflection fallback - if (plan.SourceGeneratedProperties.Length > 0) - { - // Source-generated mode - foreach (var metadata in plan.SourceGeneratedProperties) - { - var property = metadata.ContainingType.GetProperty(metadata.PropertyName); - - if (property == null || !property.CanRead) - { - continue; - } - - var value = property.GetValue(obj); - - if (value == null || !visitedObjects.Add(value)) - { - continue; - } - - // Add to the current depth level if it has injectable properties or implements IAsyncInitializer - if (PropertyInjectionCache.HasInjectableProperties(value.GetType()) || value is IAsyncInitializer) - { - if (!objectsByDepth.ContainsKey(currentDepth)) - { - objectsByDepth[currentDepth] = []; - } - - objectsByDepth[currentDepth].Add(value); - } - - // Recursively collect nested objects - if (PropertyInjectionCache.HasInjectableProperties(value.GetType())) - { - CollectNestedObjects(value, objectsByDepth, visitedObjects, currentDepth + 1); - } - } - } - else if (plan.ReflectionProperties.Length > 0) - { - // Reflection mode fallback - foreach (var prop in plan.ReflectionProperties) - { - var value = prop.Property.GetValue(obj); - - if (value == null || !visitedObjects.Add(value)) - { - continue; - } - - // Add to the current depth level if it has injectable properties or implements IAsyncInitializer - if (PropertyInjectionCache.HasInjectableProperties(value.GetType()) || value is IAsyncInitializer) - { - if (!objectsByDepth.ContainsKey(currentDepth)) - { - objectsByDepth[currentDepth] = []; - } - - objectsByDepth[currentDepth].Add(value); - } - - // Recursively collect nested objects - if (PropertyInjectionCache.HasInjectableProperties(value.GetType())) - { - CollectNestedObjects(value, objectsByDepth, visitedObjects, currentDepth + 1); - } - } - } - } - - /// - /// Clears the initialization cache. Should be called at the end of test sessions. - /// - public void ClearCache() - { - _initializationTasks.Clear(); - } -} diff --git a/TUnit.Engine/Services/EventReceiverOrchestrator.cs b/TUnit.Engine/Services/EventReceiverOrchestrator.cs index 014221efca..0d127feb35 100644 --- a/TUnit.Engine/Services/EventReceiverOrchestrator.cs +++ b/TUnit.Engine/Services/EventReceiverOrchestrator.cs @@ -5,7 +5,6 @@ using TUnit.Core.Enums; using TUnit.Core.Helpers; using TUnit.Core.Interfaces; -using TUnit.Core.Tracking; using TUnit.Engine.Events; using TUnit.Engine.Extensions; using TUnit.Engine.Logging; @@ -17,7 +16,6 @@ internal sealed class EventReceiverOrchestrator : IDisposable { private readonly EventReceiverRegistry _registry = new(); private readonly TUnitFrameworkLogger _logger; - private readonly TrackableObjectGraphProvider _trackableObjectGraphProvider; // Track which assemblies/classes/sessions have had their "first" event invoked private ThreadSafeDictionary _firstTestInAssemblyTasks = new(); @@ -35,10 +33,9 @@ internal sealed class EventReceiverOrchestrator : IDisposable // Track registered First event receiver types to avoid duplicate registrations private readonly ConcurrentHashSet _registeredFirstEventReceiverTypes = new(); - public EventReceiverOrchestrator(TUnitFrameworkLogger logger, TrackableObjectGraphProvider trackableObjectGraphProvider) + public EventReceiverOrchestrator(TUnitFrameworkLogger logger) { _logger = logger; - _trackableObjectGraphProvider = trackableObjectGraphProvider; } public void RegisterReceivers(TestContext context, CancellationToken cancellationToken) diff --git a/TUnit.Engine/Services/ObjectGraphDiscoveryService.cs b/TUnit.Engine/Services/ObjectGraphDiscoveryService.cs new file mode 100644 index 0000000000..8d5e46f7f9 --- /dev/null +++ b/TUnit.Engine/Services/ObjectGraphDiscoveryService.cs @@ -0,0 +1,198 @@ +using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; +using TUnit.Core; +using TUnit.Core.PropertyInjection; + +namespace TUnit.Engine.Services; + +/// +/// Centralized service for discovering and organizing object graphs. +/// Eliminates duplicate graph traversal logic that was scattered across +/// PropertyInjectionService, DataSourceInitializer, and TrackableObjectGraphProvider. +/// Follows Single Responsibility Principle - only discovers objects, doesn't modify them. +/// +internal sealed class ObjectGraphDiscoveryService +{ + /// + /// Discovers all objects from test context arguments and properties, organized by depth level. + /// Depth 0 = root objects (class args, method args, property values) + /// Depth 1+ = nested objects found in properties of objects at previous depth + /// + public ObjectGraph DiscoverObjectGraph(TestContext testContext) + { + var objectsByDepth = new ConcurrentDictionary>(); + var allObjects = new HashSet(); + var visitedObjects = new HashSet(); + + var testDetails = testContext.Metadata.TestDetails; + + // Collect root-level objects (depth 0) + foreach (var classArgument in testDetails.TestClassArguments) + { + if (classArgument != null && visitedObjects.Add(classArgument)) + { + AddToDepth(objectsByDepth, 0, classArgument); + allObjects.Add(classArgument); + DiscoverNestedObjects(classArgument, objectsByDepth, visitedObjects, allObjects, currentDepth: 1); + } + } + + foreach (var methodArgument in testDetails.TestMethodArguments) + { + if (methodArgument != null && visitedObjects.Add(methodArgument)) + { + AddToDepth(objectsByDepth, 0, methodArgument); + allObjects.Add(methodArgument); + DiscoverNestedObjects(methodArgument, objectsByDepth, visitedObjects, allObjects, currentDepth: 1); + } + } + + foreach (var property in testDetails.TestClassInjectedPropertyArguments.Values) + { + if (property != null && visitedObjects.Add(property)) + { + AddToDepth(objectsByDepth, 0, property); + allObjects.Add(property); + DiscoverNestedObjects(property, objectsByDepth, visitedObjects, allObjects, currentDepth: 1); + } + } + + return new ObjectGraph(objectsByDepth, allObjects); + } + + /// + /// Discovers nested objects from a single root object, organized by depth. + /// Used for discovering objects within a data source or property value. + /// + public ObjectGraph DiscoverNestedObjectGraph(object rootObject) + { + var objectsByDepth = new ConcurrentDictionary>(); + var allObjects = new HashSet(); + var visitedObjects = new HashSet(); + + if (visitedObjects.Add(rootObject)) + { + AddToDepth(objectsByDepth, 0, rootObject); + allObjects.Add(rootObject); + DiscoverNestedObjects(rootObject, objectsByDepth, visitedObjects, allObjects, currentDepth: 1); + } + + return new ObjectGraph(objectsByDepth, allObjects); + } + + /// + /// Recursively discovers nested objects that have injectable properties. + /// + [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Property discovery handles both AOT and reflection modes")] + private void DiscoverNestedObjects( + object obj, + ConcurrentDictionary> objectsByDepth, + HashSet visitedObjects, + HashSet allObjects, + int currentDepth) + { + var plan = PropertyInjectionCache.GetOrCreatePlan(obj.GetType()); + + if (!plan.HasProperties) + { + return; + } + + // Use source-generated properties if available, otherwise fall back to reflection + if (plan.SourceGeneratedProperties.Length > 0) + { + foreach (var metadata in plan.SourceGeneratedProperties) + { + var property = metadata.ContainingType.GetProperty(metadata.PropertyName); + if (property == null || !property.CanRead) + { + continue; + } + + var value = property.GetValue(obj); + if (value == null || !visitedObjects.Add(value)) + { + continue; + } + + AddToDepth(objectsByDepth, currentDepth, value); + allObjects.Add(value); + + // Recursively discover if this value has injectable properties + if (PropertyInjectionCache.HasInjectableProperties(value.GetType())) + { + DiscoverNestedObjects(value, objectsByDepth, visitedObjects, allObjects, currentDepth + 1); + } + } + } + else if (plan.ReflectionProperties.Length > 0) + { + foreach (var (property, _) in plan.ReflectionProperties) + { + var value = property.GetValue(obj); + if (value == null || !visitedObjects.Add(value)) + { + continue; + } + + AddToDepth(objectsByDepth, currentDepth, value); + allObjects.Add(value); + + // Recursively discover if this value has injectable properties + if (PropertyInjectionCache.HasInjectableProperties(value.GetType())) + { + DiscoverNestedObjects(value, objectsByDepth, visitedObjects, allObjects, currentDepth + 1); + } + } + } + } + + private static void AddToDepth(ConcurrentDictionary> objectsByDepth, int depth, object obj) + { + objectsByDepth.GetOrAdd(depth, _ => []).Add(obj); + } +} + +/// +/// Represents a discovered object graph organized by depth level. +/// +internal sealed class ObjectGraph +{ + public ObjectGraph(ConcurrentDictionary> objectsByDepth, HashSet allObjects) + { + ObjectsByDepth = objectsByDepth; + AllObjects = allObjects; + MaxDepth = objectsByDepth.Count > 0 ? objectsByDepth.Keys.Max() : -1; + } + + /// + /// Objects organized by depth (0 = root arguments, 1+ = nested). + /// + public ConcurrentDictionary> ObjectsByDepth { get; } + + /// + /// All unique objects in the graph. + /// + public HashSet AllObjects { get; } + + /// + /// Maximum nesting depth (-1 if empty). + /// + public int MaxDepth { get; } + + /// + /// Gets objects at a specific depth level. + /// + public IEnumerable GetObjectsAtDepth(int depth) + { + return ObjectsByDepth.TryGetValue(depth, out var objects) ? objects : []; + } + + /// + /// Gets depth levels in descending order (deepest first). + /// + public IEnumerable GetDepthsDescending() + { + return ObjectsByDepth.Keys.OrderByDescending(d => d); + } +} diff --git a/TUnit.Engine/Services/ObjectLifecycleService.cs b/TUnit.Engine/Services/ObjectLifecycleService.cs new file mode 100644 index 0000000000..f554d07ea0 --- /dev/null +++ b/TUnit.Engine/Services/ObjectLifecycleService.cs @@ -0,0 +1,338 @@ +using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; +using TUnit.Core; +using TUnit.Core.Interfaces; +using TUnit.Core.PropertyInjection; +using TUnit.Core.PropertyInjection.Initialization; +using TUnit.Core.Tracking; + +namespace TUnit.Engine.Services; + +/// +/// Unified service for managing object lifecycle. +/// Orchestrates: registration, property injection, initialization (IAsyncInitializer), and tracking. +/// Replaces the fragmented: PropertyInjectionService, DataSourceInitializer, PropertyInitializationOrchestrator, +/// PropertyDataResolver, and ObjectRegistrationService. +/// +/// Uses Lazy<T> for dependencies to break circular references without manual Initialize() calls. +/// Follows clear phase separation: Register → Inject → Initialize → Cleanup. +/// +internal sealed class ObjectLifecycleService : IObjectRegistry +{ + private readonly Lazy _propertyInjector; + private readonly ObjectGraphDiscoveryService _objectGraphDiscoveryService; + private readonly ObjectTracker _objectTracker; + + // Track initialization state per object + private readonly ConcurrentDictionary> _initializationTasks = new(); + + public ObjectLifecycleService( + Lazy propertyInjector, + ObjectGraphDiscoveryService objectGraphDiscoveryService, + ObjectTracker objectTracker) + { + _propertyInjector = propertyInjector; + _objectGraphDiscoveryService = objectGraphDiscoveryService; + _objectTracker = objectTracker; + } + + private PropertyInjector PropertyInjector => _propertyInjector.Value; + + #region Phase 1: Registration (Discovery Time) + + /// + /// Registers a test for lifecycle management during discovery. + /// Resolves and caches property values (to create shared objects early) without setting them on the placeholder instance. + /// 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) + { + var objectBag = testContext.StateBag.Items; + var methodMetadata = testContext.Metadata.TestDetails.MethodMetadata; + var events = testContext.InternalEvents; + var testClassType = testContext.Metadata.TestDetails.ClassType; + + // 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); + + // Track the cached objects so they get the correct reference count + _objectTracker.TrackObjects(testContext); + } + + /// + /// IObjectRegistry implementation - registers a single object. + /// Injects properties but does NOT call IAsyncInitializer (deferred to execution). + /// + public async Task RegisterObjectAsync( + object instance, + ConcurrentDictionary objectBag, + MethodMetadata? methodMetadata, + TestContextEvents events) + { + if (instance == null) + { + throw new ArgumentNullException(nameof(instance)); + } + + // Inject properties during registration + await PropertyInjector.InjectPropertiesAsync(instance, objectBag, methodMetadata, events); + } + + /// + /// IObjectRegistry implementation - registers multiple argument objects. + /// + public async Task RegisterArgumentsAsync( + object?[] arguments, + ConcurrentDictionary objectBag, + MethodMetadata? methodMetadata, + TestContextEvents events) + { + if (arguments == null || arguments.Length == 0) + { + return; + } + + var tasks = new List(); + foreach (var argument in arguments) + { + if (argument != null) + { + tasks.Add(RegisterObjectAsync(argument, objectBag, methodMetadata, events)); + } + } + + await Task.WhenAll(tasks); + } + + #endregion + + #region Phase 2: Preparation (Execution Time) + + /// + /// Prepares a test for execution. + /// Sets already-resolved cached property values on the current instance and initializes tracked objects. + /// This is needed because retries create new instances that don't have properties set yet. + /// + public async Task PrepareTestAsync(TestContext testContext, CancellationToken cancellationToken) + { + var testClassInstance = testContext.Metadata.TestDetails.ClassInstance; + + // Set already-cached property values on the current instance + // Properties were resolved and cached during RegisterTestAsync, so shared objects are already created + // We just need to set them on the actual test instance (retries create new instances) + SetCachedPropertiesOnInstance(testClassInstance, testContext); + + // Initialize all tracked objects (IAsyncInitializer) depth-first + await InitializeTrackedObjectsAsync(testContext, cancellationToken); + } + + /// + /// Sets already-cached property values on a test class instance. + /// This is used to apply cached property values to new instances created during retries. + /// + [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Reflection mode is not used in AOT")] + private void SetCachedPropertiesOnInstance(object instance, TestContext testContext) + { + var plan = PropertyInjectionCache.GetOrCreatePlan(instance.GetType()); + + if (!plan.HasProperties) + { + return; + } + + var cachedProperties = testContext.Metadata.TestDetails.TestClassInjectedPropertyArguments; + + if (plan.SourceGeneratedProperties.Length > 0) + { + foreach (var metadata in plan.SourceGeneratedProperties) + { + var cacheKey = $"{metadata.ContainingType.FullName}.{metadata.PropertyName}"; + + if (cachedProperties.TryGetValue(cacheKey, out var cachedValue) && cachedValue != null) + { + // Set the cached value on the new instance + metadata.SetProperty(instance, cachedValue); + } + } + } + else if (plan.ReflectionProperties.Length > 0) + { + foreach (var (property, _) in plan.ReflectionProperties) + { + var cacheKey = $"{property.DeclaringType!.FullName}.{property.Name}"; + + if (cachedProperties.TryGetValue(cacheKey, out var cachedValue) && cachedValue != null) + { + // Set the cached value on the new instance + var setter = PropertySetterFactory.CreateSetter(property); + setter(instance, cachedValue); + } + } + } + } + + /// + /// Initializes all tracked objects depth-first (deepest objects first). + /// + private async Task InitializeTrackedObjectsAsync(TestContext testContext, CancellationToken cancellationToken) + { + var levels = testContext.TrackedObjects.Keys.OrderByDescending(level => level); + + foreach (var level in levels) + { + var objectsAtLevel = testContext.TrackedObjects[level]; + + // Initialize all objects at this depth in parallel + await Task.WhenAll(objectsAtLevel.Select(obj => + EnsureInitializedAsync( + obj, + testContext.StateBag.Items, + testContext.Metadata.TestDetails.MethodMetadata, + testContext.InternalEvents, + cancellationToken).AsTask())); + } + + // Finally initialize the test class itself + await EnsureInitializedAsync( + testContext.Metadata.TestDetails.ClassInstance, + testContext.StateBag.Items, + testContext.Metadata.TestDetails.MethodMetadata, + testContext.InternalEvents, + cancellationToken); + } + + #endregion + + #region Phase 3: Object Initialization + + /// + /// Ensures an object is fully initialized (property injection + IAsyncInitializer). + /// Thread-safe with fast-path for already-initialized objects. + /// + public async ValueTask EnsureInitializedAsync( + T obj, + ConcurrentDictionary? objectBag = null, + MethodMetadata? methodMetadata = null, + TestContextEvents? events = null, + CancellationToken cancellationToken = default) where T : notnull + { + if (obj == null) + { + throw new ArgumentNullException(nameof(obj)); + } + + // Fast path: already initialized + if (_initializationTasks.TryGetValue(obj, out var existingTcs) && existingTcs.Task.IsCompleted) + { + if (existingTcs.Task.IsFaulted) + { + await existingTcs.Task.ConfigureAwait(false); + } + return obj; + } + + // Slow path: need to initialize + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + existingTcs = _initializationTasks.GetOrAdd(obj, tcs); + + if (existingTcs == tcs) + { + try + { + await InitializeObjectCoreAsync(obj, objectBag, methodMetadata, events, cancellationToken); + tcs.SetResult(true); + } + catch (Exception ex) + { + tcs.SetException(ex); + throw; + } + } + else + { + await existingTcs.Task.ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + } + + return obj; + } + + /// + /// Core initialization: property injection + nested objects + IAsyncInitializer. + /// + private async Task InitializeObjectCoreAsync( + object obj, + ConcurrentDictionary? objectBag, + MethodMetadata? methodMetadata, + TestContextEvents? events, + CancellationToken cancellationToken) + { + objectBag ??= new ConcurrentDictionary(); + events ??= new TestContextEvents(); + + try + { + // Step 1: Inject properties + await PropertyInjector.InjectPropertiesAsync(obj, objectBag, methodMetadata, events); + + // Step 2: Initialize nested objects depth-first + await InitializeNestedObjectsAsync(obj, cancellationToken); + + // Step 3: Call IAsyncInitializer on the object itself + if (obj is IAsyncInitializer asyncInitializer) + { + await ObjectInitializer.InitializeAsync(asyncInitializer, cancellationToken); + } + } + catch (Exception ex) + { + throw new InvalidOperationException( + $"Failed to initialize object of type '{obj.GetType().Name}': {ex.Message}", ex); + } + } + + /// + /// Initializes nested objects depth-first using the centralized ObjectGraphDiscoveryService. + /// + private async Task InitializeNestedObjectsAsync(object rootObject, CancellationToken cancellationToken) + { + var graph = _objectGraphDiscoveryService.DiscoverNestedObjectGraph(rootObject); + + // Initialize from deepest to shallowest (skip depth 0 which is the root itself) + foreach (var depth in graph.GetDepthsDescending()) + { + if (depth == 0) continue; // Root handled separately + + var objectsAtDepth = graph.GetObjectsAtDepth(depth); + + await Task.WhenAll(objectsAtDepth + .Where(obj => obj is IAsyncInitializer) + .Select(obj => ObjectInitializer.InitializeAsync(obj, cancellationToken).AsTask())); + } + } + + #endregion + + #region Phase 4: Cleanup + + /// + /// Cleans up after test execution. + /// Decrements reference counts and disposes objects when count reaches zero. + /// + public async Task CleanupTestAsync(TestContext testContext, List cleanupExceptions) + { + await _objectTracker.UntrackObjects(testContext, cleanupExceptions); + } + + #endregion + + /// + /// Clears the initialization cache. Called at end of test session. + /// + public void ClearCache() + { + _initializationTasks.Clear(); + } +} diff --git a/TUnit.Engine/Services/ObjectRegistrationService.cs b/TUnit.Engine/Services/ObjectRegistrationService.cs deleted file mode 100644 index 26b8bc2bbe..0000000000 --- a/TUnit.Engine/Services/ObjectRegistrationService.cs +++ /dev/null @@ -1,101 +0,0 @@ -using System.Collections.Concurrent; -using System.Diagnostics.CodeAnalysis; -using TUnit.Core; -using TUnit.Core.PropertyInjection; -using TUnit.Core.Tracking; - -namespace TUnit.Engine.Services; - -/// -/// Handles object registration during the test discovery/registration phase. -/// Responsibilities: Create instances, inject properties, track for disposal (ONCE per object). -/// Does NOT call IAsyncInitializer - that's deferred to ObjectInitializationService during execution. -/// -internal sealed class ObjectRegistrationService : IObjectRegistry -{ - private readonly PropertyInjectionService _propertyInjectionService; - - public ObjectRegistrationService( - PropertyInjectionService propertyInjectionService) - { - _propertyInjectionService = propertyInjectionService ?? throw new ArgumentNullException(nameof(propertyInjectionService)); - } - - /// - /// Registers a single object during the registration phase. - /// Injects properties, tracks for disposal (once), but does NOT call IAsyncInitializer. - /// - /// The object instance to register. Must not be null. - /// Shared object bag for the test context. Must not be null. - /// Method metadata for the test. Can be null. - /// Test context events for tracking. Must not be null and must be unique per test permutation. - public async Task RegisterObjectAsync( - object instance, - ConcurrentDictionary objectBag, - MethodMetadata? methodMetadata, - TestContextEvents events) - { - if (instance == null) - { - throw new ArgumentNullException(nameof(instance)); - } - - if (objectBag == null) - { - throw new ArgumentNullException(nameof(objectBag)); - } - - if (events == null) - { - throw new ArgumentNullException(nameof(events), "TestContextEvents must not be null. Each test permutation must have a unique TestContextEvents instance for proper disposal tracking."); - } - - if (RequiresPropertyInjection(instance)) - { - await _propertyInjectionService.InjectPropertiesIntoObjectAsync( - instance, - objectBag, - methodMetadata, - events); - } - } - - /// - /// Registers multiple objects (e.g., constructor/method arguments) in parallel. - /// Used during test registration to prepare arguments without executing expensive operations. - /// - public async Task RegisterArgumentsAsync( - object?[] arguments, - ConcurrentDictionary objectBag, - MethodMetadata? methodMetadata, - TestContextEvents events) - { - if (arguments == null || arguments.Length == 0) - { - return; - } - - // Process arguments in parallel for performance - var tasks = new List(); - foreach (var argument in arguments) - { - if (argument != null) - { - tasks.Add(RegisterObjectAsync(argument, objectBag, methodMetadata, events)); - } - } - - await Task.WhenAll(tasks); - } - - /// - /// Determines if an object requires property injection. - /// - #if NET6_0_OR_GREATER - [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Property injection cache handles both AOT and reflection modes appropriately")] - #endif - private bool RequiresPropertyInjection(object instance) - { - return PropertyInjectionCache.HasInjectableProperties(instance.GetType()); - } -} diff --git a/TUnit.Engine/Services/PropertyDataResolver.cs b/TUnit.Engine/Services/PropertyDataResolver.cs deleted file mode 100644 index bd50c6d5fc..0000000000 --- a/TUnit.Engine/Services/PropertyDataResolver.cs +++ /dev/null @@ -1,182 +0,0 @@ -using System.Collections.Concurrent; -using System.Diagnostics.CodeAnalysis; -using TUnit.Core; -using TUnit.Core.Helpers; -using TUnit.Core.Interfaces; -using TUnit.Core.PropertyInjection; -using TUnit.Core.PropertyInjection.Initialization; - -namespace TUnit.Engine.Services; - -/// -/// Handles all data source resolution logic for property initialization. -/// Follows Single Responsibility Principle by focusing only on data resolution. -/// -internal static class PropertyDataResolver -{ - /// - /// Resolves data from a property's data source. - /// -#if NET6_0_OR_GREATER - [RequiresUnreferencedCode("Property data resolution uses reflection on property types")] -#endif - public static async Task ResolvePropertyDataAsync(PropertyInitializationContext context, DataSourceInitializer dataSourceInitializer, IObjectRegistry objectRegistry) - { - var dataSource = await GetInitializedDataSourceAsync(context, dataSourceInitializer); - if (dataSource == null) - { - return null; - } - - var dataGeneratorMetadata = CreateDataGeneratorMetadata(context, dataSource); - var dataRows = dataSource.GetDataRowsAsync(dataGeneratorMetadata); - - // Get the first value from the data source - await foreach (var factory in dataRows) - { - var args = await factory(); - var value = ResolveValueFromArgs(context.PropertyType, args); - - // Resolve any Func wrappers - value = await ResolveDelegateValue(value); - - // Initialize the resolved value if needed - if (value != null) - { - // Ensure the value is fully initialized (property injection + IAsyncInitializer) - // DataSourceInitializer handles both data sources and regular objects - if (value is IDataSourceAttribute dataSourceValue) - { - value = await dataSourceInitializer.EnsureInitializedAsync( - dataSourceValue, - context.ObjectBag, - context.MethodMetadata, - context.Events); - } - else if (PropertyInjectionCache.HasInjectableProperties(value.GetType()) || value is IAsyncInitializer) - { - value = await dataSourceInitializer.EnsureInitializedAsync( - value, - context.ObjectBag, - context.MethodMetadata, - context.Events); - } - - return value; - } - } - - return null; - } - - /// - /// Gets an initialized data source from the context. - /// Ensures the data source is fully initialized (including property injection) before returning it. - /// - private static async Task GetInitializedDataSourceAsync(PropertyInitializationContext context, DataSourceInitializer dataSourceInitializer) - { - IDataSourceAttribute? dataSource = null; - - if (context.DataSource != null) - { - dataSource = context.DataSource; - } - else if (context.SourceGeneratedMetadata != null) - { - // Create a new data source instance - dataSource = context.SourceGeneratedMetadata.CreateDataSource(); - } - - if (dataSource == null) - { - return null; - } - - // Ensure the data source is fully initialized before use - // This handles property injection and IAsyncInitializer - return await dataSourceInitializer.EnsureInitializedAsync( - dataSource, - context.ObjectBag, - context.MethodMetadata, - context.Events); - } - - /// - /// Creates data generator metadata for the property. - /// -#if NET6_0_OR_GREATER - [RequiresUnreferencedCode("Data generator metadata creation uses reflection on property types")] -#endif - private static DataGeneratorMetadata CreateDataGeneratorMetadata( - PropertyInitializationContext context, - IDataSourceAttribute dataSource) - { - if (context.SourceGeneratedMetadata != null) - { - // Source-generated mode - if (context.SourceGeneratedMetadata.ContainingType == null) - { - throw new InvalidOperationException( - $"ContainingType is null for property '{context.PropertyName}'. " + - $"This may indicate an issue with source generator for type '{context.PropertyType.Name}'."); - } - - var propertyMetadata = new PropertyMetadata - { - IsStatic = false, - Name = context.PropertyName, - ClassMetadata = ClassMetadataHelper.GetOrCreateClassMetadata(context.SourceGeneratedMetadata.ContainingType), - Type = context.PropertyType, - ReflectionInfo = PropertyHelper.GetPropertyInfo(context.SourceGeneratedMetadata.ContainingType, context.PropertyName), - Getter = parent => PropertyHelper.GetPropertyInfo(context.SourceGeneratedMetadata.ContainingType, context.PropertyName).GetValue(parent!)!, - ContainingTypeMetadata = ClassMetadataHelper.GetOrCreateClassMetadata(context.SourceGeneratedMetadata.ContainingType) - }; - - return DataGeneratorMetadataCreator.CreateForPropertyInjection( - propertyMetadata, - context.MethodMetadata, - dataSource, - context.TestContext, - context.TestContext?.Metadata.TestDetails.ClassInstance, - context.Events, - context.ObjectBag); - } - else if (context.PropertyInfo != null) - { - // Reflection mode - return DataGeneratorMetadataCreator.CreateForPropertyInjection( - context.PropertyInfo, - context.PropertyInfo.DeclaringType!, - context.MethodMetadata, - dataSource, - context.TestContext, - context.Instance, - context.Events, - context.ObjectBag); - } - - throw new InvalidOperationException("Cannot create data generator metadata: no property information available"); - } - - /// - /// Resolves value from data source arguments, handling tuples. - /// -#if NET6_0_OR_GREATER - [RequiresUnreferencedCode("Value resolution may create tuple types dynamically")] -#endif - private static object? ResolveValueFromArgs( - [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] - Type propertyType, - object?[]? args) - { - return TupleValueResolver.ResolveTupleValue(propertyType, args); - } - - /// - /// Resolves delegate values by invoking them. - /// - private static async ValueTask ResolveDelegateValue(object? value) - { - return await PropertyValueProcessor.ResolveTestDataValueAsync(typeof(object), value); - } -} diff --git a/TUnit.Engine/Services/PropertyInitializationOrchestrator.cs b/TUnit.Engine/Services/PropertyInitializationOrchestrator.cs deleted file mode 100644 index 2b7e3ab34b..0000000000 --- a/TUnit.Engine/Services/PropertyInitializationOrchestrator.cs +++ /dev/null @@ -1,213 +0,0 @@ -using System.Collections.Concurrent; -using System.Diagnostics.CodeAnalysis; -using System.Reflection; -using TUnit.Core; -using TUnit.Core.Interfaces.SourceGenerator; -using TUnit.Core.PropertyInjection; -using TUnit.Core.PropertyInjection.Initialization; - -namespace TUnit.Engine.Services; - -/// -/// Orchestrates the entire property initialization process. -/// Coordinates between different components and manages the initialization flow. -/// -internal sealed class PropertyInitializationOrchestrator -{ - internal readonly DataSourceInitializer _dataSourceInitializer; - private readonly IObjectRegistry _objectRegistry; - - public PropertyInitializationOrchestrator(DataSourceInitializer dataSourceInitializer, IObjectRegistry? objectRegistry) - { - _dataSourceInitializer = dataSourceInitializer ?? throw new ArgumentNullException(nameof(dataSourceInitializer)); - _objectRegistry = objectRegistry!; - } - - /// - /// Initializes all properties for an instance using source-generated metadata. - /// Properties are initialized in parallel for better performance. - /// - private async Task InitializeSourceGeneratedPropertiesAsync( - object instance, - PropertyInjectionMetadata[] properties, - ConcurrentDictionary objectBag, - MethodMetadata? methodMetadata, - TestContextEvents events, - ConcurrentDictionary visitedObjects) - { - if (properties.Length == 0) - { - return; - } - - var tasks = properties.Select(metadata => - InitializeSourceGeneratedPropertyAsync(instance, metadata, objectBag, methodMetadata, events, visitedObjects)); - - await Task.WhenAll(tasks); - } - - /// - /// Initializes all properties for an instance using reflection. - /// Properties are initialized in parallel for better performance. - /// -#if NET6_0_OR_GREATER - [RequiresUnreferencedCode("Reflection-based property initialization uses PropertyInfo")] -#endif - private async Task InitializeReflectionPropertiesAsync( - object instance, - (PropertyInfo Property, IDataSourceAttribute DataSource)[] properties, - ConcurrentDictionary objectBag, - MethodMetadata? methodMetadata, - TestContextEvents events, - ConcurrentDictionary visitedObjects) - { - if (properties.Length == 0) - { - return; - } - - var tasks = properties.Select(pair => - InitializeReflectionPropertyAsync(instance, pair.Property, pair.DataSource, objectBag, methodMetadata, events, visitedObjects)); - - await Task.WhenAll(tasks); - } - - /// - /// Initializes a single property using source-generated metadata. - /// - #if NET6_0_OR_GREATER - [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Property data resolver is called for source-generated properties which are AOT-safe")] - #endif - private async Task InitializeSourceGeneratedPropertyAsync( - object instance, - PropertyInjectionMetadata metadata, - ConcurrentDictionary objectBag, - MethodMetadata? methodMetadata, - TestContextEvents events, - ConcurrentDictionary visitedObjects) - { - object? resolvedValue = null; - var testContext = TestContext.Current; - - // Check if property was pre-resolved during registration - if (testContext?.Metadata.TestDetails.TestClassInjectedPropertyArguments.TryGetValue(metadata.PropertyName, out resolvedValue) == true) - { - // Use pre-resolved value - it was already initialized during first resolution - } - else - { - // Resolve the property value from the data source - resolvedValue = await PropertyDataResolver.ResolvePropertyDataAsync( - new PropertyInitializationContext - { - Instance = instance, - SourceGeneratedMetadata = metadata, - PropertyName = metadata.PropertyName, - PropertyType = metadata.PropertyType, - PropertySetter = metadata.SetProperty, - ObjectBag = objectBag, - MethodMetadata = methodMetadata, - Events = events, - VisitedObjects = visitedObjects, - TestContext = testContext, - IsNestedProperty = false - }, - _dataSourceInitializer, - _objectRegistry); - - if (resolvedValue == null) - { - return; - } - } - - // Set the property value - metadata.SetProperty(instance, resolvedValue); - - // Store for potential reuse (using TryAdd for thread-safe concurrent access) - if (testContext != null) - { - ((ConcurrentDictionary)testContext.Metadata.TestDetails.TestClassInjectedPropertyArguments) - .TryAdd(metadata.PropertyName, resolvedValue); - } - } - - /// - /// Initializes a single property using reflection. - /// - #if NET6_0_OR_GREATER - [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Reflection-based property initialization is only used in reflection mode, not in AOT")] - #endif - private async Task InitializeReflectionPropertyAsync( - object instance, - PropertyInfo property, - IDataSourceAttribute dataSource, - ConcurrentDictionary objectBag, - MethodMetadata? methodMetadata, - TestContextEvents events, - ConcurrentDictionary visitedObjects) - { - var testContext = TestContext.Current; - var propertySetter = PropertySetterFactory.CreateSetter(property); - - // Resolve the property value from the data source - var resolvedValue = await PropertyDataResolver.ResolvePropertyDataAsync( - new PropertyInitializationContext - { - Instance = instance, - PropertyInfo = property, - DataSource = dataSource, - PropertyName = property.Name, - PropertyType = property.PropertyType, - PropertySetter = propertySetter, - ObjectBag = objectBag, - MethodMetadata = methodMetadata, - Events = events, - VisitedObjects = visitedObjects, - TestContext = testContext, - IsNestedProperty = false - }, - _dataSourceInitializer, - _objectRegistry); - - if (resolvedValue == null) - { - return; - } - - // Set the property value - propertySetter(instance, resolvedValue); - } - - /// - /// Handles the complete initialization flow for an object with properties. - /// - [UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code", Justification = "Specific source gen path")] - public async Task InitializeObjectWithPropertiesAsync( - object instance, - PropertyInjectionPlan plan, - ConcurrentDictionary objectBag, - MethodMetadata? methodMetadata, - TestContextEvents events, - ConcurrentDictionary visitedObjects) - { - if (plan.HasProperties == false) - { - return; - } - - // Initialize properties based on what's available in the plan - // For closed generic types, source-gen may not have registered them, so use reflection fallback - if (plan.SourceGeneratedProperties.Length > 0) - { - await InitializeSourceGeneratedPropertiesAsync( - instance, plan.SourceGeneratedProperties, objectBag, methodMetadata, events, visitedObjects); - } - else if (plan.ReflectionProperties.Length > 0) - { - await InitializeReflectionPropertiesAsync( - instance, plan.ReflectionProperties, objectBag, methodMetadata, events, visitedObjects); - } - } - -} diff --git a/TUnit.Engine/Services/PropertyInjectionService.cs b/TUnit.Engine/Services/PropertyInjectionService.cs deleted file mode 100644 index de10b2dfb7..0000000000 --- a/TUnit.Engine/Services/PropertyInjectionService.cs +++ /dev/null @@ -1,206 +0,0 @@ -using System.Collections.Concurrent; -using TUnit.Core; -using TUnit.Core.PropertyInjection; - -namespace TUnit.Engine.Services; - -/// -/// Internal service for property injection. -/// Used by ObjectRegistrationService during registration phase. -/// -internal sealed class PropertyInjectionService -{ - private readonly DataSourceInitializer _dataSourceInitializer; - private PropertyInitializationOrchestrator _orchestrator; - - // Simple object pool for visited objects dictionaries to reduce allocations - private static readonly ConcurrentBag> _visitedObjectsPool = new(); - - public PropertyInjectionService(DataSourceInitializer dataSourceInitializer) - { - _dataSourceInitializer = dataSourceInitializer ?? throw new ArgumentNullException(nameof(dataSourceInitializer)); - _orchestrator = new PropertyInitializationOrchestrator(dataSourceInitializer, null!); - } - - /// - /// Completes initialization by providing the ObjectRegistrationService as IObjectRegistry. - /// This two-phase initialization breaks the circular dependency while maintaining type safety. - /// - public void Initialize(IObjectRegistry objectRegistry) - { - _orchestrator = new PropertyInitializationOrchestrator(_dataSourceInitializer, objectRegistry); - } - - /// - /// Injects properties with data sources into argument objects just before test execution. - /// This ensures properties are only initialized when the test is about to run. - /// Arguments are processed in parallel for better performance. - /// - public async Task InjectPropertiesIntoArgumentsAsync(object?[] arguments, ConcurrentDictionary objectBag, MethodMetadata methodMetadata, TestContextEvents events) - { - if (arguments.Length == 0) - { - return; - } - - var injectableArgs = arguments - .Where(argument => argument != null && PropertyInjectionCache.HasInjectableProperties(argument.GetType())) - .ToArray(); - - if (injectableArgs.Length == 0) - { - return; - } - - var argumentTasks = injectableArgs - .Select(argument => InjectPropertiesIntoObjectAsync(argument!, objectBag, methodMetadata, events)) - .ToArray(); - - await Task.WhenAll(argumentTasks); - } - - - /// - /// Recursively injects properties with data sources into a single object. - /// Uses source generation mode when available, falls back to reflection mode. - /// After injection, handles tracking, initialization, and recursive injection. - /// - /// The object instance to inject properties into. - /// Shared object bag for the test context. Must not be null. - /// Method metadata for the test. Can be null. - /// Test context events for tracking. Must not be null and must be unique per test permutation. - public async Task InjectPropertiesIntoObjectAsync(object instance, ConcurrentDictionary objectBag, MethodMetadata? methodMetadata, TestContextEvents events) - { - if (objectBag == null) - { - throw new ArgumentNullException(nameof(objectBag)); - } - - if (events == null) - { - throw new ArgumentNullException(nameof(events), "TestContextEvents must not be null. Each test permutation must have a unique TestContextEvents instance for proper disposal tracking."); - } - - // Rent dictionary from pool to avoid allocations - if (!_visitedObjectsPool.TryTake(out var visitedObjects)) - { -#if NETSTANDARD2_0 - visitedObjects = new ConcurrentDictionary(); -#else - visitedObjects = new ConcurrentDictionary(ReferenceEqualityComparer.Instance); -#endif - } - - try - { - await InjectPropertiesIntoObjectAsyncCore(instance, objectBag, methodMetadata, events, visitedObjects); - } - finally - { - // Clear and return to pool (reject if too large to avoid memory bloat) - visitedObjects.Clear(); - if (visitedObjects.Count == 0) - { - _visitedObjectsPool.Add(visitedObjects); - } - } - } - - internal async Task InjectPropertiesIntoObjectAsyncCore(object instance, ConcurrentDictionary objectBag, MethodMetadata? methodMetadata, TestContextEvents events, ConcurrentDictionary visitedObjects) - { - if (instance == null) - { - return; - } - - // Prevent cycles - if (!visitedObjects.TryAdd(instance, 0)) - { - return; - } - - try - { - // Use optimized single-lookup pattern with fast-path for already-injected instances - await PropertyInjectionCache.EnsureInjectedAsync(instance, async _ => - { - var plan = PropertyInjectionCache.GetOrCreatePlan(instance.GetType()); - - await _orchestrator.InitializeObjectWithPropertiesAsync( - instance, plan, objectBag, methodMetadata, events, visitedObjects); - }); - - await RecurseIntoNestedPropertiesAsync(instance, objectBag, methodMetadata, events, visitedObjects); - } - catch (Exception ex) - { - var detailedMessage = $"Failed to inject properties for type '{instance.GetType().Name}': {ex.Message}"; - - if (ex.StackTrace != null) - { - detailedMessage += $"\nStack trace: {ex.StackTrace}"; - } - - throw new InvalidOperationException(detailedMessage, ex); - } - } - - /// - /// Recursively injects properties into nested objects that have injectable properties. - /// This is called after the direct properties of an object have been initialized. - /// - private async Task RecurseIntoNestedPropertiesAsync( - object instance, - ConcurrentDictionary objectBag, - MethodMetadata? methodMetadata, - TestContextEvents events, - ConcurrentDictionary visitedObjects) - { - var plan = PropertyInjectionCache.GetOrCreatePlan(instance.GetType()); - if (!plan.HasProperties) - { - return; - } - - // Use whichever properties are available in the plan - // For closed generic types, source-gen may not have registered them, so use reflection fallback - if (plan.SourceGeneratedProperties.Length > 0) - { - foreach (var metadata in plan.SourceGeneratedProperties) - { - var property = metadata.ContainingType.GetProperty(metadata.PropertyName); - if (property == null || !property.CanRead) - { - continue; - } - - var propertyValue = property.GetValue(instance); - if (propertyValue == null) - { - continue; - } - - if (PropertyInjectionCache.HasInjectableProperties(propertyValue.GetType())) - { - await InjectPropertiesIntoObjectAsyncCore(propertyValue, objectBag, methodMetadata, events, visitedObjects); - } - } - } - else if (plan.ReflectionProperties.Length > 0) - { - foreach (var (property, _) in plan.ReflectionProperties) - { - var propertyValue = property.GetValue(instance); - if (propertyValue == null) - { - continue; - } - - if (PropertyInjectionCache.HasInjectableProperties(propertyValue.GetType())) - { - await InjectPropertiesIntoObjectAsyncCore(propertyValue, objectBag, methodMetadata, events, visitedObjects); - } - } - } - } -} diff --git a/TUnit.Engine/Services/PropertyInjector.cs b/TUnit.Engine/Services/PropertyInjector.cs new file mode 100644 index 0000000000..55b6ff5b0c --- /dev/null +++ b/TUnit.Engine/Services/PropertyInjector.cs @@ -0,0 +1,616 @@ +using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using TUnit.Core; +using TUnit.Core.Interfaces; +using TUnit.Core.Interfaces.SourceGenerator; +using TUnit.Core.PropertyInjection; +using TUnit.Core.PropertyInjection.Initialization; + +namespace TUnit.Engine.Services; + +/// +/// Pure property injection service. +/// Follows Single Responsibility Principle - only injects property values, doesn't initialize objects. +/// Uses Lazy initialization to break circular dependencies without manual Initialize() calls. +/// +internal sealed class PropertyInjector +{ + private readonly Lazy _objectLifecycleService; + private readonly string _testSessionId; + + // Object pool for visited dictionaries to reduce allocations + private static readonly ConcurrentBag> _visitedObjectsPool = new(); + + public PropertyInjector(Lazy objectLifecycleService, string testSessionId) + { + _objectLifecycleService = objectLifecycleService; + _testSessionId = testSessionId; + } + + /// + /// Resolves and caches property values for a test class type WITHOUT setting them on an instance. + /// Used during registration to create shared objects early and enable proper reference counting. + /// + public async Task ResolveAndCachePropertiesAsync( + Type testClassType, + ConcurrentDictionary objectBag, + MethodMetadata? methodMetadata, + TestContextEvents events, + TestContext testContext) + { + var plan = PropertyInjectionCache.GetOrCreatePlan(testClassType); + + if (!plan.HasProperties) + { + return; + } + + // Resolve properties based on what's available in the plan + if (plan.SourceGeneratedProperties.Length > 0) + { + await ResolveAndCacheSourceGeneratedPropertiesAsync( + plan.SourceGeneratedProperties, objectBag, methodMetadata, events, testContext); + } + else if (plan.ReflectionProperties.Length > 0) + { + await ResolveAndCacheReflectionPropertiesAsync( + plan.ReflectionProperties, objectBag, methodMetadata, events, testContext); + } + } + + /// + /// Injects properties into an object and recursively into nested objects. + /// + public async Task InjectPropertiesAsync( + object instance, + ConcurrentDictionary objectBag, + MethodMetadata? methodMetadata, + TestContextEvents events) + { + if (instance == null) + { + throw new ArgumentNullException(nameof(instance)); + } + + if (objectBag == null) + { + throw new ArgumentNullException(nameof(objectBag)); + } + + if (events == null) + { + throw new ArgumentNullException(nameof(events)); + } + + // Rent dictionary from pool + if (!_visitedObjectsPool.TryTake(out var visitedObjects)) + { +#if NETSTANDARD2_0 + visitedObjects = new ConcurrentDictionary(); +#else + visitedObjects = new ConcurrentDictionary(ReferenceEqualityComparer.Instance); +#endif + } + + try + { + await InjectPropertiesRecursiveAsync(instance, objectBag, methodMetadata, events, visitedObjects); + } + finally + { + visitedObjects.Clear(); + _visitedObjectsPool.Add(visitedObjects); + } + } + + /// + /// Injects properties into multiple argument objects in parallel. + /// + public async Task InjectPropertiesIntoArgumentsAsync( + object?[] arguments, + ConcurrentDictionary objectBag, + MethodMetadata methodMetadata, + TestContextEvents events) + { + if (arguments.Length == 0) + { + return; + } + + var injectableArgs = arguments + .Where(arg => arg != null && PropertyInjectionCache.HasInjectableProperties(arg.GetType())) + .ToArray(); + + if (injectableArgs.Length == 0) + { + return; + } + + await Task.WhenAll(injectableArgs.Select(arg => + InjectPropertiesAsync(arg!, objectBag, methodMetadata, events))); + } + + private async Task InjectPropertiesRecursiveAsync( + object instance, + ConcurrentDictionary objectBag, + MethodMetadata? methodMetadata, + TestContextEvents events, + ConcurrentDictionary visitedObjects) + { + if (instance == null) + { + return; + } + + // Prevent cycles + if (!visitedObjects.TryAdd(instance, 0)) + { + return; + } + + try + { + var plan = PropertyInjectionCache.GetOrCreatePlan(instance.GetType()); + + if (plan.HasProperties) + { + // Initialize properties based on what's available in the plan + if (plan.SourceGeneratedProperties.Length > 0) + { + await InjectSourceGeneratedPropertiesAsync( + instance, plan.SourceGeneratedProperties, objectBag, methodMetadata, events, visitedObjects); + } + else if (plan.ReflectionProperties.Length > 0) + { + await InjectReflectionPropertiesAsync( + instance, plan.ReflectionProperties, objectBag, methodMetadata, events, visitedObjects); + } + } + + // Recurse into nested properties + await RecurseIntoNestedPropertiesAsync(instance, plan, objectBag, methodMetadata, events, visitedObjects); + } + catch (Exception ex) + { + throw new InvalidOperationException( + $"Failed to inject properties for type '{instance.GetType().Name}': {ex.Message}", ex); + } + } + + private async Task InjectSourceGeneratedPropertiesAsync( + object instance, + PropertyInjectionMetadata[] properties, + ConcurrentDictionary objectBag, + MethodMetadata? methodMetadata, + TestContextEvents events, + ConcurrentDictionary visitedObjects) + { + if (properties.Length == 0) + { + return; + } + + // Initialize properties in parallel + await Task.WhenAll(properties.Select(metadata => + InjectSourceGeneratedPropertyAsync(instance, metadata, objectBag, methodMetadata, events, visitedObjects))); + } + + [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Source-gen properties are AOT-safe")] + private async Task InjectSourceGeneratedPropertyAsync( + object instance, + PropertyInjectionMetadata metadata, + ConcurrentDictionary objectBag, + MethodMetadata? methodMetadata, + TestContextEvents events, + ConcurrentDictionary visitedObjects) + { + // 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 + var property = metadata.ContainingType.GetProperty(metadata.PropertyName); + if (property != null && property.CanRead) + { + var existingValue = property.GetValue(instance); + if (existingValue != null) + { + // Property already has a value, don't overwrite it + return; + } + } + + var testContext = TestContext.Current; + object? resolvedValue = null; + + // Use a composite key to avoid conflicts when nested classes have properties with the same name + var cacheKey = $"{metadata.ContainingType.FullName}.{metadata.PropertyName}"; + + // Check if property was pre-resolved during registration + if (testContext?.Metadata.TestDetails.TestClassInjectedPropertyArguments.TryGetValue(cacheKey, out resolvedValue) == true) + { + // Use pre-resolved value + } + else + { + // Resolve the property value from the data source + resolvedValue = await ResolvePropertyDataAsync( + new PropertyInitializationContext + { + Instance = instance, + SourceGeneratedMetadata = metadata, + PropertyName = metadata.PropertyName, + PropertyType = metadata.PropertyType, + PropertySetter = metadata.SetProperty, + ObjectBag = objectBag, + MethodMetadata = methodMetadata, + Events = events, + VisitedObjects = visitedObjects, + TestContext = testContext, + IsNestedProperty = false + }); + + if (resolvedValue == null) + { + return; + } + } + + // Set the property value + metadata.SetProperty(instance, resolvedValue); + + // Store for potential reuse with composite key + if (testContext != null) + { + ((ConcurrentDictionary)testContext.Metadata.TestDetails.TestClassInjectedPropertyArguments) + .TryAdd(cacheKey, resolvedValue); + } + } + + [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Reflection mode is not used in AOT")] + private async Task InjectReflectionPropertiesAsync( + object instance, + (PropertyInfo Property, IDataSourceAttribute DataSource)[] properties, + ConcurrentDictionary objectBag, + MethodMetadata? methodMetadata, + TestContextEvents events, + ConcurrentDictionary visitedObjects) + { + if (properties.Length == 0) + { + return; + } + + await Task.WhenAll(properties.Select(pair => + InjectReflectionPropertyAsync(instance, pair.Property, pair.DataSource, objectBag, methodMetadata, events, visitedObjects))); + } + + [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Reflection mode is not used in AOT")] + private async Task InjectReflectionPropertyAsync( + object instance, + PropertyInfo property, + IDataSourceAttribute dataSource, + ConcurrentDictionary objectBag, + MethodMetadata? methodMetadata, + TestContextEvents events, + ConcurrentDictionary visitedObjects) + { + var testContext = TestContext.Current; + var propertySetter = PropertySetterFactory.CreateSetter(property); + + var resolvedValue = await ResolvePropertyDataAsync( + new PropertyInitializationContext + { + Instance = instance, + PropertyInfo = property, + DataSource = dataSource, + PropertyName = property.Name, + PropertyType = property.PropertyType, + PropertySetter = propertySetter, + ObjectBag = objectBag, + MethodMetadata = methodMetadata, + Events = events, + VisitedObjects = visitedObjects, + TestContext = testContext, + IsNestedProperty = false + }); + + if (resolvedValue == null) + { + return; + } + + propertySetter(instance, resolvedValue); + } + + private async Task RecurseIntoNestedPropertiesAsync( + object instance, + PropertyInjectionPlan plan, + ConcurrentDictionary objectBag, + MethodMetadata? methodMetadata, + TestContextEvents events, + ConcurrentDictionary visitedObjects) + { + if (!plan.HasProperties) + { + return; + } + + if (plan.SourceGeneratedProperties.Length > 0) + { + foreach (var metadata in plan.SourceGeneratedProperties) + { + var property = metadata.ContainingType.GetProperty(metadata.PropertyName); + if (property == null || !property.CanRead) + { + continue; + } + + var propertyValue = property.GetValue(instance); + if (propertyValue == null) + { + continue; + } + + if (PropertyInjectionCache.HasInjectableProperties(propertyValue.GetType())) + { + await InjectPropertiesRecursiveAsync(propertyValue, objectBag, methodMetadata, events, visitedObjects); + } + } + } + else if (plan.ReflectionProperties.Length > 0) + { + foreach (var (property, _) in plan.ReflectionProperties) + { + var propertyValue = property.GetValue(instance); + if (propertyValue == null) + { + continue; + } + + if (PropertyInjectionCache.HasInjectableProperties(propertyValue.GetType())) + { + await InjectPropertiesRecursiveAsync(propertyValue, objectBag, methodMetadata, events, visitedObjects); + } + } + } + } + + private async Task ResolveAndCacheSourceGeneratedPropertiesAsync( + PropertyInjectionMetadata[] properties, + ConcurrentDictionary objectBag, + MethodMetadata? methodMetadata, + TestContextEvents events, + TestContext testContext) + { + if (properties.Length == 0) + { + return; + } + + // Resolve properties in parallel + await Task.WhenAll(properties.Select(metadata => + ResolveAndCacheSourceGeneratedPropertyAsync(metadata, objectBag, methodMetadata, events, testContext))); + } + + [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Source-gen properties are AOT-safe")] + private async Task ResolveAndCacheSourceGeneratedPropertyAsync( + PropertyInjectionMetadata metadata, + ConcurrentDictionary objectBag, + MethodMetadata? methodMetadata, + TestContextEvents events, + TestContext testContext) + { + var cacheKey = $"{metadata.ContainingType.FullName}.{metadata.PropertyName}"; + + // Check if already cached + if (testContext.Metadata.TestDetails.TestClassInjectedPropertyArguments.ContainsKey(cacheKey)) + { + return; + } + + // Resolve the property value from the data source + var resolvedValue = await ResolvePropertyDataAsync( + new PropertyInitializationContext + { + Instance = PlaceholderInstance.Instance, // Use placeholder during registration + SourceGeneratedMetadata = metadata, + PropertyName = metadata.PropertyName, + PropertyType = metadata.PropertyType, + PropertySetter = metadata.SetProperty, + ObjectBag = objectBag, + MethodMetadata = methodMetadata, + Events = events, + VisitedObjects = new ConcurrentDictionary(), // Empty dictionary for cycle detection + TestContext = testContext, + IsNestedProperty = false + }); + + if (resolvedValue != null) + { + // Cache the resolved value + ((ConcurrentDictionary)testContext.Metadata.TestDetails.TestClassInjectedPropertyArguments) + .TryAdd(cacheKey, resolvedValue); + } + } + + [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Reflection mode is not used in AOT")] + private async Task ResolveAndCacheReflectionPropertiesAsync( + (PropertyInfo Property, IDataSourceAttribute DataSource)[] properties, + ConcurrentDictionary objectBag, + MethodMetadata? methodMetadata, + TestContextEvents events, + TestContext testContext) + { + if (properties.Length == 0) + { + return; + } + + await Task.WhenAll(properties.Select(pair => + ResolveAndCacheReflectionPropertyAsync(pair.Property, pair.DataSource, objectBag, methodMetadata, events, testContext))); + } + + [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Reflection mode is not used in AOT")] + private async Task ResolveAndCacheReflectionPropertyAsync( + PropertyInfo property, + IDataSourceAttribute dataSource, + ConcurrentDictionary objectBag, + MethodMetadata? methodMetadata, + TestContextEvents events, + TestContext testContext) + { + var cacheKey = $"{property.DeclaringType!.FullName}.{property.Name}"; + + // Check if already cached + if (testContext.Metadata.TestDetails.TestClassInjectedPropertyArguments.ContainsKey(cacheKey)) + { + return; + } + + var propertySetter = PropertySetterFactory.CreateSetter(property); + + var resolvedValue = await ResolvePropertyDataAsync( + new PropertyInitializationContext + { + Instance = PlaceholderInstance.Instance, // Use placeholder during registration + PropertyInfo = property, + DataSource = dataSource, + PropertyName = property.Name, + PropertyType = property.PropertyType, + PropertySetter = propertySetter, + ObjectBag = objectBag, + MethodMetadata = methodMetadata, + Events = events, + VisitedObjects = new ConcurrentDictionary(), // Empty dictionary for cycle detection + TestContext = testContext, + IsNestedProperty = false + }); + + if (resolvedValue != null) + { + // Cache the resolved value + ((ConcurrentDictionary)testContext.Metadata.TestDetails.TestClassInjectedPropertyArguments) + .TryAdd(cacheKey, resolvedValue); + } + } + + /// + /// Resolves data from a property's data source. + /// + [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) + { + var dataSource = await GetInitializedDataSourceAsync(context); + if (dataSource == null) + { + return null; + } + + var dataGeneratorMetadata = CreateDataGeneratorMetadata(context, dataSource); + var dataRows = dataSource.GetDataRowsAsync(dataGeneratorMetadata); + + await foreach (var factory in dataRows) + { + var args = await factory(); + var value = TupleValueResolver.ResolveTupleValue(context.PropertyType, args); + + // Resolve any Func wrappers + value = await PropertyValueProcessor.ResolveTestDataValueAsync(typeof(object), value); + + if (value != null) + { + // Ensure nested objects are initialized + if (PropertyInjectionCache.HasInjectableProperties(value.GetType()) || value is IAsyncInitializer) + { + await _objectLifecycleService.Value.EnsureInitializedAsync( + value, + context.ObjectBag, + context.MethodMetadata, + context.Events); + } + + return value; + } + } + + return null; + } + + private async Task GetInitializedDataSourceAsync(PropertyInitializationContext context) + { + IDataSourceAttribute? dataSource = null; + + if (context.DataSource != null) + { + dataSource = context.DataSource; + } + else if (context.SourceGeneratedMetadata != null) + { + dataSource = context.SourceGeneratedMetadata.CreateDataSource(); + } + + if (dataSource == null) + { + return null; + } + + // Ensure the data source is initialized + return await _objectLifecycleService.Value.EnsureInitializedAsync( + dataSource, + context.ObjectBag, + context.MethodMetadata, + context.Events); + } + + [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Metadata creation handles both modes")] + [UnconditionalSuppressMessage("Trimming", "IL2072", Justification = "ContainingType and PropertyType are preserved through source generation")] + private DataGeneratorMetadata CreateDataGeneratorMetadata( + PropertyInitializationContext context, + IDataSourceAttribute dataSource) + { + if (context.SourceGeneratedMetadata != null) + { + if (context.SourceGeneratedMetadata.ContainingType == null) + { + throw new InvalidOperationException( + $"ContainingType is null for property '{context.PropertyName}'."); + } + + var propertyMetadata = new PropertyMetadata + { + IsStatic = false, + Name = context.PropertyName, + ClassMetadata = ClassMetadataHelper.GetOrCreateClassMetadata(context.SourceGeneratedMetadata.ContainingType), + Type = context.PropertyType, + ReflectionInfo = PropertyHelper.GetPropertyInfo(context.SourceGeneratedMetadata.ContainingType, context.PropertyName), + Getter = parent => PropertyHelper.GetPropertyInfo(context.SourceGeneratedMetadata.ContainingType, context.PropertyName).GetValue(parent!)!, + ContainingTypeMetadata = ClassMetadataHelper.GetOrCreateClassMetadata(context.SourceGeneratedMetadata.ContainingType) + }; + + return DataGeneratorMetadataCreator.CreateForPropertyInjection( + propertyMetadata, + context.MethodMetadata, + dataSource, + _testSessionId, + context.TestContext, + context.TestContext?.Metadata.TestDetails.ClassInstance, + context.Events, + context.ObjectBag); + } + else if (context.PropertyInfo != null) + { + return DataGeneratorMetadataCreator.CreateForPropertyInjection( + context.PropertyInfo, + context.PropertyInfo.DeclaringType!, + context.MethodMetadata, + dataSource, + _testSessionId, + context.TestContext, + context.Instance, + context.Events, + context.ObjectBag); + } + + throw new InvalidOperationException("Cannot create data generator metadata: no property information available"); + } +} diff --git a/TUnit.Engine/Services/TestArgumentRegistrationService.cs b/TUnit.Engine/Services/TestArgumentRegistrationService.cs index dd9c4e10c5..7b4b496658 100644 --- a/TUnit.Engine/Services/TestArgumentRegistrationService.cs +++ b/TUnit.Engine/Services/TestArgumentRegistrationService.cs @@ -1,160 +1,46 @@ -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; using TUnit.Core; -using TUnit.Core.Data; -using TUnit.Core.Enums; -using TUnit.Core.Interfaces; -using TUnit.Core.Interfaces.SourceGenerator; -using TUnit.Core.PropertyInjection; -using TUnit.Core.Tracking; namespace TUnit.Engine.Services; /// -/// Service that handles registration of test arguments (constructor args, method args) during test discovery. -/// Implements ITestRegisteredEventReceiver to register objects when tests are registered. -/// Renamed from TestArgumentTrackingService to clarify it's for the registration phase. +/// Internal service that handles registration of test arguments during test discovery. +/// Not a user-extensibility point - called directly by TestBuilder. +/// Simplified to use ObjectLifecycleService for all object registration. /// -internal sealed class TestArgumentRegistrationService : ITestRegisteredEventReceiver +internal sealed class TestArgumentRegistrationService { - private readonly ObjectRegistrationService _objectRegistrationService; - private readonly ObjectTracker _objectTracker; + private readonly ObjectLifecycleService _objectLifecycleService; - public TestArgumentRegistrationService(ObjectRegistrationService objectRegistrationService, ObjectTracker objectTracker) + public TestArgumentRegistrationService(ObjectLifecycleService objectLifecycleService) { - _objectRegistrationService = objectRegistrationService; - _objectTracker = objectTracker; + _objectLifecycleService = objectLifecycleService; } - public int Order => int.MinValue; // Run first to ensure registration happens before other event receivers - /// - /// Called when a test is registered. This is the correct time to register constructor and method arguments + /// Called when a test is registered. Registers constructor and method arguments /// for proper reference counting and disposal tracking. + /// Property values are resolved lazily during test execution (not during discovery). /// - public async ValueTask OnTestRegistered(TestRegisteredContext context) + public async ValueTask RegisterTestArgumentsAsync(TestContext testContext) { - var testContext = context.TestContext; var classArguments = testContext.Metadata.TestDetails.TestClassArguments; var methodArguments = testContext.Metadata.TestDetails.TestMethodArguments; - // Register class arguments (registration phase - property injection + tracking, NO IAsyncInitializer) - await _objectRegistrationService.RegisterArgumentsAsync( + // Register class arguments (property injection during registration) + await _objectLifecycleService.RegisterArgumentsAsync( classArguments, testContext.StateBag.Items, testContext.Metadata.TestDetails.MethodMetadata, testContext.InternalEvents); - // Register method arguments (registration phase) - await _objectRegistrationService.RegisterArgumentsAsync( + // Register method arguments + await _objectLifecycleService.RegisterArgumentsAsync( methodArguments, testContext.StateBag.Items, testContext.Metadata.TestDetails.MethodMetadata, testContext.InternalEvents); - // Register properties that will be injected into the test class - await RegisterPropertiesAsync(testContext); - - _objectTracker.TrackObjects(testContext); - } - - /// - /// Registers properties that will be injected into the test class instance. - /// This ensures proper reference counting for all property-injected instances during discovery. - /// Exceptions during data generation will be caught and associated with the test for reporting. - /// - private async ValueTask RegisterPropertiesAsync(TestContext testContext) - { - try - { - var classType = testContext.Metadata.TestDetails.ClassType; - - // Get the property source for the class - var propertySource = PropertySourceRegistry.GetSource(classType); - if (propertySource?.ShouldInitialize != true) - { - // No properties to inject for this class - return; - } - - // Get all properties that need injection - var propertyMetadata = propertySource.GetPropertyMetadata(); - - foreach (var metadata in propertyMetadata) - { - try - { - // Create the data source for this property - var dataSource = metadata.CreateDataSource(); - - // Create minimal DataGeneratorMetadata for property resolution during registration - var testBuilderContext = new TestBuilderContext - { - TestMetadata = testContext.Metadata.TestDetails.MethodMetadata, - DataSourceAttribute = dataSource, - Events = testContext.InternalEvents, - StateBag = testContext.StateBag.Items - }; - - var dataGenMetadata = new DataGeneratorMetadata - { - TestBuilderContext = new TestBuilderContextAccessor(testBuilderContext), - MembersToGenerate = [], // Properties don't use member generation - TestInformation = testContext.Metadata.TestDetails.MethodMetadata, - Type = DataGeneratorType.Property, - TestSessionId = TestSessionContext.Current?.Id ?? "registration", - TestClassInstance = null, // Not available during registration - ClassInstanceArguments = testContext.Metadata.TestDetails.TestClassArguments - }; - - // Get the data rows from the data source - var dataRows = dataSource.GetDataRowsAsync(dataGenMetadata); - - // Get the first data row (properties get single values, not multiple) - await foreach (var dataRowFunc in dataRows) - { - var dataRow = await dataRowFunc(); - if (dataRow is { Length: > 0 }) - { - var data = dataRow[0]; - - if (data != null) - { - // Store for later injection - testContext.Metadata.TestDetails.TestClassInjectedPropertyArguments[metadata.PropertyName] = data; - - // Register the ClassDataSource instance during registration phase - // This does: property injection + tracking (NO IAsyncInitializer - deferred to execution) - await _objectRegistrationService.RegisterObjectAsync( - data, - testContext.StateBag.Items, - testContext.Metadata.TestDetails.MethodMetadata, - testContext.InternalEvents); - } - } - break; // Only take the first result for property injection - } - } - catch (Exception ex) - { - // Capture the exception for this property and re-throw - // The test building process will handle marking it as failed - var exceptionMessage = $"Failed to generate data for property '{metadata.PropertyName}': {ex.Message}"; - var propertyException = new InvalidOperationException(exceptionMessage, ex); - throw propertyException; - } - } - } - catch (Exception ex) - { - // Capture any top-level exceptions (e.g., getting property source) and re-throw - // The test building process will handle marking it as failed - var exceptionMessage = $"Failed to register properties for test '{testContext.Metadata.TestDetails.TestName}': {ex.Message}"; - var registrationException = new InvalidOperationException(exceptionMessage, ex); - throw registrationException; - } + // Register the test for tracking (inject properties and track objects for disposal) + await _objectLifecycleService.RegisterTestAsync(testContext); } } diff --git a/TUnit.Engine/Services/TestFilterService.cs b/TUnit.Engine/Services/TestFilterService.cs index ee091095fd..0c0285ba62 100644 --- a/TUnit.Engine/Services/TestFilterService.cs +++ b/TUnit.Engine/Services/TestFilterService.cs @@ -74,7 +74,7 @@ private async Task RegisterTest(AbstractExecutableTest test) try { - await testArgumentRegistrationService.OnTestRegistered(registeredContext); + await testArgumentRegistrationService.RegisterTestArgumentsAsync(test.Context); } catch (Exception ex) { diff --git a/TUnit.Engine/TestInitializer.cs b/TUnit.Engine/TestInitializer.cs index f9912932e4..de117c6746 100644 --- a/TUnit.Engine/TestInitializer.cs +++ b/TUnit.Engine/TestInitializer.cs @@ -1,71 +1,31 @@ -using System.Collections.Concurrent; using TUnit.Core; -using TUnit.Core.Tracking; -using TUnit.Engine.Extensions; using TUnit.Engine.Services; namespace TUnit.Engine; +/// +/// Simplified test initializer that delegates to ObjectLifecycleService. +/// Follows Single Responsibility Principle - only coordinates test initialization. +/// internal class TestInitializer { private readonly EventReceiverOrchestrator _eventReceiverOrchestrator; - private readonly PropertyInjectionService _propertyInjectionService; - private readonly DataSourceInitializer _dataSourceInitializer; - private readonly ObjectTracker _objectTracker; + private readonly ObjectLifecycleService _objectLifecycleService; - public TestInitializer(EventReceiverOrchestrator eventReceiverOrchestrator, - PropertyInjectionService propertyInjectionService, - DataSourceInitializer dataSourceInitializer, - ObjectTracker objectTracker) + public TestInitializer( + EventReceiverOrchestrator eventReceiverOrchestrator, + ObjectLifecycleService objectLifecycleService) { _eventReceiverOrchestrator = eventReceiverOrchestrator; - _propertyInjectionService = propertyInjectionService; - _dataSourceInitializer = dataSourceInitializer; - _objectTracker = objectTracker; + _objectLifecycleService = objectLifecycleService; } public async ValueTask InitializeTest(AbstractExecutableTest test, CancellationToken cancellationToken) { - var testClassInstance = test.Context.Metadata.TestDetails.ClassInstance; - - await _propertyInjectionService.InjectPropertiesIntoObjectAsync( - testClassInstance, - test.Context.StateBag.Items, - test.Context.Metadata.TestDetails.MethodMetadata, - test.Context.InternalEvents); - + // Register event receivers _eventReceiverOrchestrator.RegisterReceivers(test.Context, cancellationToken); - // Shouldn't retrack already tracked objects, but will track any new ones created during retries / initialization - _objectTracker.TrackObjects(test.Context); - - await InitializeTrackedObjects(test.Context, cancellationToken); - } - - private async Task InitializeTrackedObjects(TestContext testContext, CancellationToken cancellationToken) - { - // Initialize by level (deepest first), with objects at the same level in parallel - // Using DataSourceInitializer ensures property injection + nested objects + IAsyncInitializer - var levels = testContext.TrackedObjects.Keys.OrderByDescending(level => level); - - foreach (var level in levels) - { - var objectsAtLevel = testContext.TrackedObjects[level]; - await Task.WhenAll(objectsAtLevel.Select(obj => - _dataSourceInitializer.EnsureInitializedAsync( - obj, - testContext.StateBag.Items, - testContext.Metadata.TestDetails.MethodMetadata, - testContext.InternalEvents, - cancellationToken).AsTask())); - } - - // Finally, ensure the test class itself is initialized (property injection + IAsyncInitializer) - await _dataSourceInitializer.EnsureInitializedAsync( - testContext.Metadata.TestDetails.ClassInstance, - testContext.StateBag.Items, - testContext.Metadata.TestDetails.MethodMetadata, - testContext.InternalEvents, - cancellationToken); + // Prepare test: inject properties, track objects, initialize (IAsyncInitializer) + await _objectLifecycleService.PrepareTestAsync(test.Context, cancellationToken); } } diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt index d3d12fecde..a445e9e1f2 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt @@ -2494,7 +2494,7 @@ namespace . public sealed class PropertyInjectionMetadata { public PropertyInjectionMetadata() { } - [.(..PublicProperties)] + [.(..None | ..PublicProperties | ..NonPublicProperties)] public required ContainingType { get; init; } public required <.IDataSourceAttribute> CreateDataSource { get; init; } public required string PropertyName { get; init; } diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt index 3d1bb5b556..a1f6b35877 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt @@ -2494,7 +2494,7 @@ namespace . public sealed class PropertyInjectionMetadata { public PropertyInjectionMetadata() { } - [.(..PublicProperties)] + [.(..None | ..PublicProperties | ..NonPublicProperties)] public required ContainingType { get; init; } public required <.IDataSourceAttribute> CreateDataSource { get; init; } public required string PropertyName { get; init; } diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt index 7f3054b2c2..e295333e1a 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt @@ -2494,7 +2494,7 @@ namespace . public sealed class PropertyInjectionMetadata { public PropertyInjectionMetadata() { } - [.(..PublicProperties)] + [.(..None | ..PublicProperties | ..NonPublicProperties)] public required ContainingType { get; init; } public required <.IDataSourceAttribute> CreateDataSource { get; init; } public required string PropertyName { get; init; }