using System.Collections.Concurrent; #if NET8_0_OR_GREATER using System.Collections.Frozen; #endif using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Reflection; using TUnit.Core.Extensions; using TUnit.Core.Interfaces; using TUnit.Core.Interfaces.SourceGenerator; using TUnit.Core.PropertyInjection; using TUnit.Core.Tracking; namespace TUnit.Core.Discovery; /// /// Represents an error that occurred during object graph discovery. /// /// The name of the type being inspected. /// The name of the property that failed to access. /// The error message. /// The exception that occurred. internal readonly record struct DiscoveryError(string TypeName, string PropertyName, string ErrorMessage, Exception Exception); /// /// Centralized service for discovering and organizing object graphs. /// Consolidates duplicate graph traversal logic from ObjectGraphDiscoveryService and TrackableObjectGraphProvider. /// Follows Single Responsibility Principle - only discovers objects, doesn't modify them. /// /// /// /// This class is thread-safe and uses cached reflection for performance. /// Objects are organized by their nesting depth in the hierarchy: /// /// /// Depth 0: Root objects (class args, method args, property values) /// Depth 1+: Nested objects found in properties of objects at previous depth /// /// /// Discovery errors (e.g., property access failures) are collected in /// rather than thrown, allowing discovery to continue despite individual property failures. /// /// internal sealed class ObjectGraphDiscoverer : IObjectGraphTracker { /// /// Maximum recursion depth for object graph discovery. /// Prevents stack overflow on deep or circular object graphs. /// private const int MaxRecursionDepth = 50; // Reference equality comparer for object tracking (ignores Equals overrides) private static readonly Helpers.ReferenceEqualityComparer ReferenceComparer = Helpers.ReferenceEqualityComparer.Instance; // Types to skip during discovery (primitives, strings, system types). // Frozen on net8+ for faster lookups; this set is read-many during per-test traversal. #if NET8_0_OR_GREATER private static readonly System.Collections.Frozen.FrozenSet SkipTypes = #else private static readonly HashSet SkipTypes = #endif new HashSet { typeof(string), typeof(decimal), typeof(DateTime), typeof(DateTimeOffset), typeof(TimeSpan), typeof(Guid) } #if NET8_0_OR_GREATER .ToFrozenSet() #endif ; // Thread-safe collection of discovery errors for diagnostics private static readonly ConcurrentBag DiscoveryErrors = []; // Memoize ShouldSkipType — Namespace.StartsWith("System") is expensive when repeated // across every per-test initializer traversal. private static readonly ConcurrentDictionary ShouldSkipTypeCache = new(); // Cached flattened InitializerPropertyInfo[] for each type, including base-class // properties with derived-first precedence. Traversal would otherwise walk the // inheritance chain and allocate a HashSet on every call. // - non-null array: type has registered source-gen metadata (at this or an ancestor level). // - null : no source-gen registration anywhere in hierarchy — use reflection fallback. private static readonly ConcurrentDictionary FlattenedInitializerPropertiesCache = new(); /// /// Gets all discovery errors that occurred during object graph traversal. /// Useful for debugging and diagnostics when property access fails. /// /// A read-only list of discovery errors. public static IReadOnlyList GetDiscoveryErrors() { return DiscoveryErrors.ToArray(); } /// /// Clears all recorded discovery errors. Call at end of test session. /// public static void ClearDiscoveryErrors() { DiscoveryErrors.Clear(); } /// /// Delegate for adding discovered objects to collections. /// Returns true if the object was newly added (not a duplicate). /// private delegate bool TryAddObjectFunc(object obj, int depth); /// /// Delegate for recursive discovery after an object is added. /// private delegate void RecurseFunc(object obj, int depth); /// /// Delegate for processing a root object after it's been added. /// private delegate void RootObjectCallback(object obj); /// public ObjectGraph DiscoverObjectGraph(TestContext testContext, CancellationToken cancellationToken = default) { var objectsByDepth = new Dictionary>(); var visitedObjects = new HashSet(ReferenceComparer); // Standard mode add callback (thread-safe) bool TryAddStandard(object obj, int depth) { if (!visitedObjects.Add(obj)) { return false; } TryAddToHashSet(objectsByDepth, depth, obj); return true; } // Collect root-level objects and discover nested objects CollectRootObjects( testContext.Metadata.TestDetails, TryAddStandard, obj => DiscoverNestedObjects(obj, objectsByDepth, visitedObjects, currentDepth: 1, cancellationToken), cancellationToken); return new ObjectGraph(objectsByDepth); } /// public ObjectGraph DiscoverNestedObjectGraph(object rootObject, CancellationToken cancellationToken = default) { var objectsByDepth = new Dictionary>(); var visitedObjects = new HashSet(ReferenceComparer); if (visitedObjects.Add(rootObject)) { TryAddToHashSet(objectsByDepth, 0, rootObject); if (MayHaveNestedObjects(rootObject.GetType())) { DiscoverNestedObjects(rootObject, objectsByDepth, visitedObjects, currentDepth: 1, cancellationToken); } } return new ObjectGraph(objectsByDepth); } /// /// Discovers objects and adds them to the existing tracked objects dictionary. /// Used by TrackableObjectGraphProvider to populate TestContext.TrackedObjects. /// /// The test context to discover objects from. /// Cancellation token for the operation. /// The tracked objects dictionary (same as testContext.TrackedObjects). public SortedList> DiscoverAndTrackObjects(TestContext testContext, CancellationToken cancellationToken = default) => TrackObjectsCore(testContext, discoverNested: true, cancellationToken); /// public SortedList> TrackRootObjects(TestContext testContext) // No nested traversal: reading the roots' properties is what failed. => TrackObjectsCore(testContext, discoverNested: false, CancellationToken.None); internal SortedList> DiscoverStaticPropertyTrackableObjects(object root, CancellationToken cancellationToken) { var objects = new SortedList> { [0] = new(ReferenceComparer) { root } }; DiscoverNestedObjectsForTracking(root, objects, 1, heldByExternalOwner: false, injectedValues: null, cancellationToken); return objects; } private SortedList> TrackObjectsCore(TestContext testContext, bool discoverNested, CancellationToken cancellationToken) { var visitedObjects = testContext.TrackedObjects; var testDetails = testContext.Metadata.TestDetails; if (testDetails.TestClassArguments.Length == 0 && testDetails.TestMethodArguments.Length == 0 && testDetails.TestClassInjectedPropertyArguments.Count == 0 && testDetails.UnreceivedDataRowObjects.Length == 0) { // No root objects, so nothing to discover — skip the traversal closures. return visitedObjects; } TryAddObjectFunc tryAdd = (obj, depth) => TryAddToHashSet(visitedObjects, depth, obj); HashSet? injectedValues = null; void DiscoverRoot(object obj) { if (ObjectTracker.IsExternallyOwned(obj) && injectedValues is null) { injectedValues = new HashSet(ReferenceComparer); foreach (var value in testDetails.TestClassInjectedPropertyArguments.Values) { if (value is not null) { injectedValues.Add(value); } } } DiscoverNestedObjectsForTracking(obj, visitedObjects, 1, heldByExternalOwner: false, injectedValues, cancellationToken); } RootObjectCallback onRootObjectAdded = discoverNested ? DiscoverRoot : static _ => { }; // Collect root-level objects and discover nested objects for tracking CollectRootObjects(testDetails, tryAdd, onRootObjectAdded, cancellationToken); // Row objects that the test never receives (a failed test's row, or a class row that only a // temporary instance used) are tracked here only, never initialized: tracking them gives them // the same reference-counted disposal as arguments, so they are disposed when the test completes // unless another test still holds a shared instance (#7123, #7157). if (testDetails.UnreceivedDataRowObjects.Length > 0) { TrackUnreceivedDataRowObjects(testContext, visitedObjects, tryAdd, onRootObjectAdded, cancellationToken); } return visitedObjects; } private static void TrackUnreceivedDataRowObjects( TestContext testContext, SortedList> visitedObjects, TryAddObjectFunc tryAdd, RootObjectCallback onRootObjectAdded, CancellationToken cancellationToken) { // Objects the test receives were added above. Anything this step adds is reachable only from the // unreceived rows, so execution must not initialize it: it may already be disposed, for example // by the temporary instance that used it (#7157). var receivedObjects = new HashSet(ReferenceComparer); foreach (var bucket in visitedObjects.Values) { receivedObjects.UnionWith(bucket); } ProcessRootCollection(testContext.Metadata.TestDetails.UnreceivedDataRowObjects, tryAdd, onRootObjectAdded, cancellationToken); HashSet? disposalOnly = null; foreach (var bucket in visitedObjects.Values) { foreach (var obj in bucket) { if (!receivedObjects.Contains(obj)) { (disposalOnly ??= new HashSet(ReferenceComparer)).Add(obj); } } } if (disposalOnly is not null) { testContext.DisposalOnlyTrackedObjects = disposalOnly; } } /// /// Recursively discovers nested objects that have injectable properties OR implement IAsyncInitializer. /// Uses consolidated TraverseInjectableProperties and TraverseInitializerProperties methods. /// private void DiscoverNestedObjects( object obj, Dictionary> objectsByDepth, HashSet visitedObjects, int currentDepth, CancellationToken cancellationToken) { if (!CheckRecursionDepth(obj, currentDepth)) { return; } cancellationToken.ThrowIfCancellationRequested(); // Nothing can be found beneath this object (e.g. a primitive argument) — skip the // traversal and its per-call closures. if (!MayHaveNestedObjects(obj.GetType())) { return; } DiscoverNestedObjectsCore(obj, objectsByDepth, visitedObjects, currentDepth, cancellationToken); } private void DiscoverNestedObjectsCore( object obj, Dictionary> objectsByDepth, HashSet visitedObjects, int currentDepth, CancellationToken cancellationToken) { // Standard mode add callback: visitedObjects + objectsByDepth + allObjects (thread-safe) bool TryAddStandard(object value, int depth) { if (!visitedObjects.Add(value)) { return false; } TryAddToHashSet(objectsByDepth, depth, value); return true; } // Recursive callback void Recurse(object value, int depth) { DiscoverNestedObjects(value, objectsByDepth, visitedObjects, depth, cancellationToken); } // Traverse injectable properties (useSourceRegistrarCheck = false) TraverseInjectableProperties(obj, TryAddStandard, Recurse, currentDepth, cancellationToken, useSourceRegistrarCheck: false); // Also discover nested IAsyncInitializer objects from ALL properties TraverseInitializerProperties(obj, TryAddStandard, Recurse, currentDepth, cancellationToken); } /// /// Discovers nested objects for tracking (uses HashSet pattern for compatibility with TestContext.TrackedObjects). /// Uses consolidated TraverseInjectableProperties and TraverseInitializerProperties methods. /// /// /// True when was found beneath an object that another owner (a dependency /// injection scope) disposes. Such an object is treated as externally owned even when it is not marked. /// private void DiscoverNestedObjectsForTracking( object obj, IDictionary> visitedObjects, int currentDepth, bool heldByExternalOwner, HashSet? injectedValues, CancellationToken cancellationToken) { if (!CheckRecursionDepth(obj, currentDepth)) { return; } cancellationToken.ThrowIfCancellationRequested(); // Nothing can be found beneath this object (e.g. a primitive argument) — skip the // traversal and its per-call closures. if (!MayHaveNestedObjects(obj.GetType())) { return; } var externallyOwned = heldByExternalOwner || ObjectTracker.IsExternallyOwned(obj); if (externallyOwned) { injectedValues ??= new HashSet(ReferenceComparer); } DiscoverNestedObjectsForTrackingCore(obj, visitedObjects, currentDepth, externallyOwned, injectedValues, cancellationToken); } private void DiscoverNestedObjectsForTrackingCore( object obj, IDictionary> visitedObjects, int currentDepth, bool externallyOwned, HashSet? injectedValues, CancellationToken cancellationToken) { // Tracking mode add callback: TryAddToHashSet only bool TryAddTracking(object value, int depth) { return TryAddToHashSet(visitedObjects, depth, value); } // Recursive callback. TUnit creates the values of injectable properties, so the tracker owns them. void Recurse(object value, int depth) { DiscoverNestedObjectsForTracking(value, visitedObjects, depth, heldByExternalOwner: false, injectedValues, cancellationToken); } bool TryAddInjected(object value, int depth) { injectedValues?.Add(value); return TryAddToHashSet(visitedObjects, depth, value); } if (!externallyOwned) { // Traverse injectable properties (useSourceRegistrarCheck = true for tracking mode) TraverseInjectableProperties(obj, TryAddInjected, Recurse, currentDepth, cancellationToken, useSourceRegistrarCheck: true); // Also discover nested IAsyncInitializer objects from ALL properties TraverseInitializerProperties(obj, TryAddTracking, Recurse, currentDepth, cancellationToken); return; } // TUnit injected these values even though obj is externally owned, so the tracker still owns them. // Keep this set across the whole traversal: a nested initializer may expose an alias of // an injected fixture, and that alias must not transfer ownership to the scope. TraverseInjectableProperties(obj, TryAddInjected, Recurse, currentDepth, cancellationToken, useSourceRegistrarCheck: true); // The owner of obj (a dependency injection scope) created the rest of what obj holds and disposes // it, so the tracker must not dispose it a second time (#7205). The objects are still tracked, so // they are initialized. bool TryAddOwned(object value, int depth) { if (injectedValues?.Contains(value) == true) { return false; } if (value is IDisposable or IAsyncDisposable) { ObjectTracker.MarkExternallyOwned(value); } return TryAddToHashSet(visitedObjects, depth, value); } void RecurseOwned(object value, int depth) { DiscoverNestedObjectsForTracking(value, visitedObjects, depth, heldByExternalOwner: true, injectedValues, cancellationToken); } TraverseInitializerProperties(obj, TryAddOwned, RecurseOwned, currentDepth, cancellationToken); } /// /// Clears all caches. Called at end of test session to release memory. /// public static void ClearCache() { PropertyCacheManager.ClearCache(); TypeHierarchyCache.Clear(); ShouldSkipTypeCache.Clear(); FlattenedInitializerPropertiesCache.Clear(); MayHaveNestedObjectsCache.Clear(); ClearDiscoveryErrors(); } // Memoized per type: whether DiscoverNestedObjects could find anything beneath an instance. private static readonly ConcurrentDictionary MayHaveNestedObjectsCache = new(); /// /// Returns false when is guaranteed to find nothing /// beneath an instance of (no injectable properties and no property that /// could hold an ). Lets callers skip building a graph — two /// collections, a set and several closures — for the common plain test class. /// Mirrors the traversal rules of exactly. /// [UnconditionalSuppressMessage("Trimming", "IL2067", Justification = "Reflection fallback mirrors TraverseInitializerPropertiesViaReflection.")] internal static bool MayHaveNestedObjects(Type type) => MayHaveNestedObjectsCache.GetOrAdd(type, static t => { if (PropertyInjectionCache.GetOrCreatePlan(t).HasProperties) { return true; } if (ShouldSkipType(t)) { return false; } var flattened = GetFlattenedInitializerProperties(t); if (flattened != null) { return flattened.Length > 0; } foreach (var property in PropertyCacheManager.GetCachedProperties(t)) { if (typeof(IAsyncInitializer).IsAssignableFrom(property.PropertyType)) { return true; } } return false; }); /// /// Checks if a type should be skipped during discovery. Result is memoized per type — /// the underlying check (Namespace.StartsWith) is stable for any given type. /// private static bool ShouldSkipType(Type type) => ShouldSkipTypeCache.GetOrAdd(type, static t => t.IsPrimitive || SkipTypes.Contains(t) || t.Namespace?.StartsWith("System", StringComparison.Ordinal) == true); /// /// Add to HashSet at specified depth. Returns true if added (not duplicate). /// private static bool TryAddToHashSet(IDictionary> dict, int depth, object obj) { var hashSet = dict.GetOrAdd(depth, _ => new HashSet(ReferenceComparer)); return hashSet.Add(obj); } #region Consolidated Traversal Methods (DRY) /// /// Checks recursion depth guard. Returns false if depth exceeded (caller should return early). /// private static bool CheckRecursionDepth(object obj, int currentDepth) { if (currentDepth > MaxRecursionDepth) { #if DEBUG Debug.WriteLine($"[ObjectGraphDiscoverer] Max recursion depth ({MaxRecursionDepth}) reached for type '{obj.GetType().Name}'"); #endif return false; } return true; } /// /// Unified traversal for injectable properties (from PropertyInjectionCache). /// Eliminates duplicate code between DiscoverNestedObjects and DiscoverNestedObjectsForTracking. /// [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Property discovery handles both AOT and reflection modes")] private static void TraverseInjectableProperties( object obj, TryAddObjectFunc tryAdd, RecurseFunc recurse, int currentDepth, CancellationToken cancellationToken, bool useSourceRegistrarCheck) { var plan = PropertyInjectionCache.GetOrCreatePlan(obj.GetType()); if (!plan.HasProperties && !useSourceRegistrarCheck) { return; } // Both modes should use source-gen ONLY if we actually have source-generated properties. // This ensures fallback to reflection when source-gen is enabled but no metadata was generated // for a specific type (e.g., user's WebApplicationFactory that wasn't processed by the generator). bool useSourceGen = plan.SourceGeneratedProperties.Length > 0; if (useSourceGen) { TraverseSourceGeneratedProperties(obj, plan.SourceGeneratedProperties, tryAdd, recurse, currentDepth, cancellationToken); } else if (plan.ReflectionProperties.Length > 0) { TraverseReflectionProperties(obj, plan.ReflectionProperties, tryAdd, recurse, currentDepth, cancellationToken); } } /// /// Traverses source-generated properties and discovers nested objects. /// Extracted for reduced complexity in TraverseInjectableProperties. /// [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Property discovery handles both AOT and reflection modes")] private static void TraverseSourceGeneratedProperties( object obj, PropertyInjectionMetadata[] sourceGeneratedProperties, TryAddObjectFunc tryAdd, RecurseFunc recurse, int currentDepth, CancellationToken cancellationToken) { foreach (var metadata in sourceGeneratedProperties) { cancellationToken.ThrowIfCancellationRequested(); // Use the compile-time-generated getter when the source generator emitted one, // otherwise fall back to a cached reflection-based getter. Eliminates the // per-test Type.GetProperty reflection lookup on the hot path. // null getter => property is not readable; skip. var getter = metadata.GetOrCreateGetter(); if (getter is null) { continue; } var value = getter(obj); if (value != null && tryAdd(value, currentDepth)) { recurse(value, currentDepth + 1); } } } /// /// Traverses reflection-based properties and discovers nested objects. /// Extracted for reduced complexity in TraverseInjectableProperties. /// private static void TraverseReflectionProperties( object obj, (PropertyInfo Property, IDataSourceAttribute DataSource)[] reflectionProperties, TryAddObjectFunc tryAdd, RecurseFunc recurse, int currentDepth, CancellationToken cancellationToken) { foreach (var prop in reflectionProperties) { cancellationToken.ThrowIfCancellationRequested(); var value = prop.Property.GetValue(obj); if (value != null && tryAdd(value, currentDepth)) { recurse(value, currentDepth + 1); } } } /// /// Unified traversal for IAsyncInitializer objects (from all properties). /// Eliminates duplicate code between DiscoverNestedInitializerObjects and DiscoverNestedInitializerObjectsForTracking. /// /// /// Uses source-generated metadata when available (AOT-compatible), falling back to reflection otherwise. /// Exceptions during property access are propagated to the caller with context about /// which type/property failed. This ensures data source initialization failures are /// properly reported as test failures rather than silently swallowed. /// [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Reflection fallback for nested initializers. In AOT, source-gen handles primary discovery.")] [UnconditionalSuppressMessage("Trimming", "IL2070", Justification = "Reflection fallback for nested initializers. In AOT, source-gen handles primary discovery.")] [UnconditionalSuppressMessage("Trimming", "IL2075", Justification = "Reflection fallback for nested initializers. In AOT, source-gen handles primary discovery.")] private static void TraverseInitializerProperties( object obj, TryAddObjectFunc tryAdd, RecurseFunc recurse, int currentDepth, CancellationToken cancellationToken) { var type = obj.GetType(); if (ShouldSkipType(type)) { return; } // Fetch the pre-flattened source-gen property list for this type. The flattening // walks the inheritance chain once and caches the deduplicated result, so per-test // traversal does not re-walk BaseType nor allocate a HashSet. var flattened = GetFlattenedInitializerProperties(type); if (flattened != null) { TraverseFlattenedInitializerProperties(obj, type, flattened, tryAdd, recurse, currentDepth, cancellationToken); return; } // No source-gen registration anywhere in the hierarchy → fall back to reflection. TraverseInitializerPropertiesViaReflection(obj, type, tryAdd, recurse, currentDepth, cancellationToken); } /// /// Returns the cached flattened InitializerPropertyInfo[] for a type (inheritance-walked, /// derived-first precedence, deduplicated by property name). Returns null when no /// source-gen registration exists in the type's inheritance chain. /// private static InitializerPropertyInfo[]? GetFlattenedInitializerProperties(Type type) => FlattenedInitializerPropertiesCache.GetOrAdd(type, static t => { List? merged = null; HashSet? seen = null; for (var currentType = t; currentType != null && currentType != typeof(object); currentType = currentType.BaseType) { var registered = InitializerPropertyRegistry.GetProperties(currentType); if (registered == null) { continue; } merged ??= new List(registered.Length); seen ??= new HashSet(StringComparer.Ordinal); foreach (var p in registered) { if (seen.Add(p.PropertyName)) { merged.Add(p); } } } return merged?.ToArray(); }); /// /// Traverses the pre-flattened source-generated IAsyncInitializer properties. /// No per-call inheritance walk or HashSet allocation — that work was done once /// during flattening and cached in . /// private static void TraverseFlattenedInitializerProperties( object obj, Type type, InitializerPropertyInfo[] properties, TryAddObjectFunc tryAdd, RecurseFunc recurse, int currentDepth, CancellationToken cancellationToken) { foreach (var propInfo in properties) { cancellationToken.ThrowIfCancellationRequested(); try { var value = propInfo.GetValue(obj); if (value == null) { continue; } if (value is IAsyncInitializer && tryAdd(value, currentDepth)) { recurse(value, currentDepth + 1); } } catch (OperationCanceledException) { throw; } catch (Exception ex) { DiscoveryErrors.Add(new DiscoveryError(type.Name, propInfo.PropertyName, ex.Message, ex)); throw DataSourceException.FromNestedFailure( $"Failed to access property '{propInfo.PropertyName}' on type '{type.Name}' during object graph discovery. " + $"This may indicate that a data source or its nested dependencies failed to initialize. " + $"See inner exception for details.", ex); } } } /// /// Traverses IAsyncInitializer properties using reflection (fallback for non-source-generated types). /// [UnconditionalSuppressMessage("Trimming", "IL2067", Justification = "Reflection fallback path. Types without source-generated metadata may not work in trimmed apps.")] private static void TraverseInitializerPropertiesViaReflection( object obj, Type type, TryAddObjectFunc tryAdd, RecurseFunc recurse, int currentDepth, CancellationToken cancellationToken) { var properties = PropertyCacheManager.GetCachedProperties(type); foreach (var property in properties) { cancellationToken.ThrowIfCancellationRequested(); // Only access properties whose declared type could be IAsyncInitializer. // This prevents triggering side effects from unrelated property getters // (e.g., WebApplicationFactory.Server which starts the test host when accessed). // Properties typed as object/interfaces not extending IAsyncInitializer won't be // checked - users should properly type their properties or use data source attributes. if (!typeof(IAsyncInitializer).IsAssignableFrom(property.PropertyType)) { continue; } try { var value = property.GetValue(obj); if (value == null) { continue; } // Only discover IAsyncInitializer objects if (value is IAsyncInitializer && tryAdd(value, currentDepth)) { recurse(value, currentDepth + 1); } } catch (OperationCanceledException) { throw; // Propagate cancellation } catch (Exception ex) { // Record error for diagnostics (still available via GetDiscoveryErrors()) DiscoveryErrors.Add(new DiscoveryError(type.Name, property.Name, ex.Message, ex)); // Propagate the exception with context about which property failed // This ensures data source failures are reported as test failures throw DataSourceException.FromNestedFailure( $"Failed to access property '{property.Name}' on type '{type.Name}' during object graph discovery. " + $"This may indicate that a data source or its nested dependencies failed to initialize. " + $"See inner exception for details.", ex); } } } /// /// Collects root-level objects (class args, method args, properties) from test details. /// Eliminates duplicate loops in DiscoverObjectGraph and DiscoverAndTrackObjects. /// /// /// For injected properties, only DIRECT test class properties (including inherited) are added at depth 0. /// Nested properties (properties of injected objects) are discovered through normal /// graph traversal at appropriate depths (1+), ensuring correct initialization order /// for nested IAsyncInitializer dependencies. See GitHub issue #4032. /// private static void CollectRootObjects( TestDetails testDetails, TryAddObjectFunc tryAdd, RootObjectCallback onRootObjectAdded, CancellationToken cancellationToken) { // Process class arguments ProcessRootCollection(testDetails.TestClassArguments, tryAdd, onRootObjectAdded, cancellationToken); // Process method arguments ProcessRootCollection(testDetails.TestMethodArguments, tryAdd, onRootObjectAdded, cancellationToken); // Build set of types in the test class hierarchy (for identifying direct properties) var hierarchyTypes = GetTypeHierarchy(testDetails.ClassType); // Process ONLY direct test class injected properties at depth 0. // Nested properties will be discovered through normal graph traversal at depth 1+. // This ensures proper initialization order for nested IAsyncInitializer dependencies. foreach (var kvp in testDetails.TestClassInjectedPropertyArguments) { cancellationToken.ThrowIfCancellationRequested(); if (kvp.Value == null) { continue; } // Check if this property belongs to the test class hierarchy (not nested object properties) // Cache key format: "{DeclaringType.FullName}.{PropertyName}" if (IsDirectProperty(kvp.Key, hierarchyTypes)) { if (tryAdd(kvp.Value, 0)) { onRootObjectAdded(kvp.Value); } } } } /// /// Cache of type hierarchy sets keyed by . Per-type hierarchy is invariant, /// and each invocation otherwise allocates a fresh of strings. /// Populated on first access per type and read-only thereafter. The stored /// instance is shared — callers MUST treat it as read-only. /// (Not typed as IReadOnlySet{T} because netstandard2.0 lacks the interface and /// the only consumer, , needs the concrete type for /// on newer TFMs.) /// private static readonly ConcurrentDictionary> TypeHierarchyCache = new(); /// /// Gets all types in the inheritance hierarchy from the given type up to (but not including) object. /// The returned is shared across callers and MUST NOT be mutated. /// private static HashSet GetTypeHierarchy(Type type) => TypeHierarchyCache.GetOrAdd(type, static t => { var result = new HashSet(); var currentType = t; while (currentType != null && currentType != typeof(object)) { if (currentType.FullName != null) { result.Add(currentType.FullName); } currentType = currentType.BaseType; } return result; }); /// /// Determines if a cache key represents a direct property (belonging to test class hierarchy) /// vs a nested property (belonging to an injected object). /// Cache key format: "{DeclaringType.FullName}.{PropertyName}" /// private static bool IsDirectProperty(string cacheKey, HashSet hierarchyTypes) { // Find the last dot to separate type from property name var lastDotIndex = cacheKey.LastIndexOf('.'); if (lastDotIndex <= 0) { return true; // Malformed key, treat as direct } #if NET9_0_OR_GREATER var declaringTypeName = cacheKey.AsSpan()[..lastDotIndex]; return hierarchyTypes.GetAlternateLookup>().Contains(declaringTypeName); #else var declaringTypeName = cacheKey.Substring(0, lastDotIndex); return hierarchyTypes.Contains(declaringTypeName); #endif } /// /// Processes a collection of root objects, adding them to the graph and invoking callback. /// Extracted to eliminate duplicate iteration patterns in CollectRootObjects. /// private static void ProcessRootCollection( IEnumerable collection, TryAddObjectFunc tryAdd, RootObjectCallback onRootObjectAdded, CancellationToken cancellationToken) { foreach (var item in collection) { cancellationToken.ThrowIfCancellationRequested(); ProcessRootObject(item, tryAdd, onRootObjectAdded, cancellationToken, 0); } } private static void ProcessRootObject( object? item, TryAddObjectFunc tryAdd, RootObjectCallback onRootObjectAdded, CancellationToken cancellationToken, int tupleDepth) { cancellationToken.ThrowIfCancellationRequested(); if (item is null || !tryAdd(item, 0)) { return; } onRootObjectAdded(item); // A tuple is an argument container. Its elements retain the same lifecycle ownership // as separate row arguments, including disposable objects without an initializer. // Keep elements at root depth so wrapping a row does not alter initialization order. if (tupleDepth >= MaxRecursionDepth) { return; } if (item is Helpers.TupleArgument retained) { foreach (var element in retained.Elements) { ProcessRootObject(element, tryAdd, onRootObjectAdded, cancellationToken, tupleDepth + 1); } return; } #if NET if (item is System.Runtime.CompilerServices.ITuple tuple) { for (var i = 0; i < tuple.Length; i++) { ProcessRootObject(tuple[i], tryAdd, onRootObjectAdded, cancellationToken, tupleDepth + 1); } } #else if (Helpers.TupleHelper.IsTupleType(item)) { foreach (var element in Helpers.DataSourceHelpers.UnwrapTupleAot(item)) { ProcessRootObject(element, tryAdd, onRootObjectAdded, cancellationToken, tupleDepth + 1); } } #endif } #endregion }