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
}