diff --git a/TUnit.Core.SourceGenerator/CodeGenerators/StaticPropertyInitializationGenerator.cs b/TUnit.Core.SourceGenerator/CodeGenerators/StaticPropertyInitializationGenerator.cs index b2f3f60476..f149dbacd1 100644 --- a/TUnit.Core.SourceGenerator/CodeGenerators/StaticPropertyInitializationGenerator.cs +++ b/TUnit.Core.SourceGenerator/CodeGenerators/StaticPropertyInitializationGenerator.cs @@ -314,11 +314,13 @@ private static void GenerateAsyncDataSourceGeneratorWithProperty(CodeWriter writ { var generatorCode = CodeGenerationHelpers.GenerateAttributeInstantiation(attr); writer.AppendLine($"var generator = {generatorCode};"); + writer.AppendLine("// Use the global static property context for disposal tracking"); + writer.AppendLine("var globalContext = global::TUnit.Core.TestSessionContext.GlobalStaticPropertyContext;"); writer.AppendLine("var metadata = new global::TUnit.Core.DataGeneratorMetadata"); writer.AppendLine("{"); writer.Indent(); writer.AppendLine("Type = global::TUnit.Core.Enums.DataGeneratorType.Property,"); - writer.AppendLine("TestBuilderContext = null,"); + writer.AppendLine("TestBuilderContext = new global::TUnit.Core.TestBuilderContextAccessor(globalContext),"); writer.AppendLine("MembersToGenerate = new global::TUnit.Core.MemberMetadata[] { propertyMetadata },"); writer.AppendLine("TestInformation = null,"); writer.AppendLine("TestSessionId = global::TUnit.Core.TestSessionContext.Current?.Id ?? \"static-property-init\","); diff --git a/TUnit.Core/Attributes/TestData/ClassDataSources.cs b/TUnit.Core/Attributes/TestData/ClassDataSources.cs index 8b536f20fd..b513ac31a9 100644 --- a/TUnit.Core/Attributes/TestData/ClassDataSources.cs +++ b/TUnit.Core/Attributes/TestData/ClassDataSources.cs @@ -14,9 +14,24 @@ private ClassDataSources() public static readonly GetOnlyDictionary SourcesPerSession = new(); - public static ClassDataSources Get(string sessionId) => SourcesPerSession.GetOrAdd(sessionId, _ => new()); + public static ClassDataSources Get(string sessionId) + { + var isNew = false; + var result = SourcesPerSession.GetOrAdd(sessionId, _ => + { + isNew = true; + return new ClassDataSources(); + }); + + if (isNew) + { + Console.WriteLine($"[ClassDataSources] Created new ClassDataSources for session {sessionId}"); + } + + return result; + } - public (T, SharedType, string) GetItemForIndexAsync<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.PublicMethods)] T>(int index, Type? testClassType, SharedType[] sharedTypes, string[] keys, DataGeneratorMetadata dataGeneratorMetadata) where T : new() + public (T, SharedType, string) GetItemForIndexAsync<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.PublicMethods)] T>(int index, Type testClassType, SharedType[] sharedTypes, string[] keys, DataGeneratorMetadata dataGeneratorMetadata) where T : new() { var shared = sharedTypes.ElementAtOrDefault(index); @@ -37,82 +52,30 @@ private string GetKey(int index, SharedType[] sharedTypes, string[] keys) return keys.ElementAtOrDefault(keyedIndex) ?? throw new ArgumentException($"Key at index {keyedIndex} not found"); } - public T Get<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.PublicMethods)] T>(SharedType sharedType, Type? testClassType, string key, DataGeneratorMetadata dataGeneratorMetadata) + public T Get<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.PublicMethods)] T>(SharedType sharedType, Type testClassType, string key, DataGeneratorMetadata dataGeneratorMetadata) { -#pragma warning disable CS8603 // Possible null reference return. - if (sharedType == SharedType.None) - { - return Create(dataGeneratorMetadata); - } - - if (sharedType == SharedType.PerTestSession) - { - return (T) TestDataContainer.GetGlobalInstance(typeof(T), () => Create(typeof(T), dataGeneratorMetadata)); - } - - if (sharedType == SharedType.PerClass) - { - if (testClassType == null) - { - throw new InvalidOperationException($"Cannot use SharedType.PerClass without a test class type. This may occur during static property initialization."); - } - return (T) TestDataContainer.GetInstanceForClass(testClassType, typeof(T), () => Create(typeof(T), dataGeneratorMetadata)); - } - - if (sharedType == SharedType.Keyed) + return sharedType switch { - return (T) TestDataContainer.GetInstanceForKey(key, typeof(T), () => Create(typeof(T), dataGeneratorMetadata)); - } - - if (sharedType == SharedType.PerAssembly) - { - if (testClassType == null) - { - throw new InvalidOperationException($"Cannot use SharedType.PerAssembly without a test class type. This may occur during static property initialization."); - } - return (T) TestDataContainer.GetInstanceForAssembly(testClassType.Assembly, typeof(T), () => Create(typeof(T), dataGeneratorMetadata)); - } -#pragma warning restore CS8603 // Possible null reference return. - - throw new ArgumentOutOfRangeException(); + SharedType.None => Create(dataGeneratorMetadata), + SharedType.PerTestSession => (T) TestDataContainer.GetGlobalInstance(typeof(T), _ => Create(typeof(T), dataGeneratorMetadata))!, + SharedType.PerClass => (T) TestDataContainer.GetInstanceForClass(testClassType, typeof(T), _ => Create(typeof(T), dataGeneratorMetadata))!, + SharedType.Keyed => (T) TestDataContainer.GetInstanceForKey(key, typeof(T), _ => Create(typeof(T), dataGeneratorMetadata))!, + SharedType.PerAssembly => (T) TestDataContainer.GetInstanceForAssembly(testClassType.Assembly, typeof(T), _ => Create(typeof(T), dataGeneratorMetadata))!, + _ => throw new ArgumentOutOfRangeException() + }; } - public object Get(SharedType sharedType, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.PublicMethods)] Type type, Type? testClassType, string? key, DataGeneratorMetadata dataGeneratorMetadata) + public object? Get(SharedType sharedType, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.PublicMethods)] Type type, Type testClassType, string? key, DataGeneratorMetadata dataGeneratorMetadata) { - if (sharedType == SharedType.None) - { - return Create(type, dataGeneratorMetadata); - } - - if (sharedType == SharedType.PerTestSession) - { - return TestDataContainer.GetGlobalInstance(type, () => Create(type, dataGeneratorMetadata)); - } - - if (sharedType == SharedType.PerClass) - { - if (testClassType == null) - { - throw new InvalidOperationException($"Cannot use SharedType.PerClass without a test class type. This may occur during static property initialization."); - } - return TestDataContainer.GetInstanceForClass(testClassType, type, () => Create(type, dataGeneratorMetadata)); - } - - if (sharedType == SharedType.Keyed) + return sharedType switch { - return TestDataContainer.GetInstanceForKey(key!, type, () => Create(type, dataGeneratorMetadata)); - } - - if (sharedType == SharedType.PerAssembly) - { - if (testClassType == null) - { - throw new InvalidOperationException($"Cannot use SharedType.PerAssembly without a test class type. This may occur during static property initialization."); - } - return TestDataContainer.GetInstanceForAssembly(testClassType.Assembly, type, () => Create(type, dataGeneratorMetadata)); - } - - throw new ArgumentOutOfRangeException(); + SharedType.None => Create(type, dataGeneratorMetadata), + SharedType.PerTestSession => TestDataContainer.GetGlobalInstance(type, _ => Create(type, dataGeneratorMetadata)), + SharedType.PerClass => TestDataContainer.GetInstanceForClass(testClassType, type, _ => Create(type, dataGeneratorMetadata)), + SharedType.Keyed => TestDataContainer.GetInstanceForKey(key!, type, _ => Create(type, dataGeneratorMetadata)), + SharedType.PerAssembly => TestDataContainer.GetInstanceForAssembly(testClassType.Assembly, type, _ => Create(type, dataGeneratorMetadata)), + _ => throw new ArgumentOutOfRangeException() + }; } [return: NotNull] @@ -125,17 +88,7 @@ private static object Create([DynamicallyAccessedMembers(DynamicallyAccessedMemb { try { - var instance = Activator.CreateInstance(type)!; - - // Track the created object - var trackerEvents2 = dataGeneratorMetadata.TestBuilderContext?.Current.Events; - - if (trackerEvents2 != null) - { - ObjectTracker.TrackObject(trackerEvents2, instance); - } - - return instance; + return Activator.CreateInstance(type)!; } catch (TargetInvocationException targetInvocationException) { diff --git a/TUnit.Core/Attributes/TestData/DependencyInjectionDataSourceSourceAttribute.cs b/TUnit.Core/Attributes/TestData/DependencyInjectionDataSourceSourceAttribute.cs index b7ce003fa2..51e7fd407d 100644 --- a/TUnit.Core/Attributes/TestData/DependencyInjectionDataSourceSourceAttribute.cs +++ b/TUnit.Core/Attributes/TestData/DependencyInjectionDataSourceSourceAttribute.cs @@ -16,7 +16,7 @@ public abstract class DependencyInjectionDataSourceAttribute : UntypedDa { if (scope is IAsyncDisposable asyncDisposable) { - await asyncDisposable.DisposeAsync(); + await asyncDisposable.DisposeAsync().ConfigureAwait(false); } else if (scope is IDisposable disposable) { diff --git a/TUnit.Core/Data/DependencyTracker.cs b/TUnit.Core/Data/DependencyTracker.cs deleted file mode 100644 index 40aacf6935..0000000000 --- a/TUnit.Core/Data/DependencyTracker.cs +++ /dev/null @@ -1,87 +0,0 @@ -using System.Runtime.CompilerServices; - -namespace TUnit.Core.Data; - -/// -/// Tracks nested dependencies between objects for proper disposal ordering. -/// -internal class DependencyTracker -{ - // Use ConditionalWeakTable to avoid preventing garbage collection - private readonly ConditionalWeakTable> _dependencies = new(); - private readonly object _lock = new(); - private int _dependencyCount; - - /// - /// Registers a nested dependency relationship. - /// - /// The type of the child object. - /// The parent object. - /// The child object that the parent depends on. - /// The scope manager responsible for the child object. - public void RegisterDependency(object parent, T child, IScopeManager scopeManager) - { - if (parent == null || child == null || scopeManager == null) - { - return; - } - - lock (_lock) - { - var dependencies = _dependencies.GetOrCreateValue(parent); - var wasEmpty = dependencies.Count == 0; - dependencies.Add(new ScopedReference(child, scopeManager)); - - // Only increment count if this is the first dependency for this parent - if (wasEmpty) - { - _dependencyCount++; - } - } - } - - /// - /// Disposes all nested dependencies for the specified parent object. - /// - /// The parent object whose dependencies should be disposed. - /// A task representing the disposal operation. - public async Task DisposeNestedDependenciesAsync(object parent) - { - if (parent == null) - { - return; - } - - List? dependencies = null; - var hadDependencies = false; - - lock (_lock) - { - if (_dependencies.TryGetValue(parent, out dependencies)) - { - _dependencies.Remove(parent); - hadDependencies = true; - _dependencyCount--; - } - } - - if (dependencies != null && hadDependencies) - { - // Dispose dependencies in parallel for better performance - var disposalTasks = dependencies.Select(dep => dep.DisposeAsync()); - await Task.WhenAll(disposalTasks); - } - } - - /// - /// Gets the number of objects that have nested dependencies. - /// - /// The count of parent objects with dependencies. - public int GetDependencyCount() - { - lock (_lock) - { - return _dependencyCount; - } - } -} diff --git a/TUnit.Core/Data/IDisposableReference.cs b/TUnit.Core/Data/IDisposableReference.cs deleted file mode 100644 index 42ce2533d5..0000000000 --- a/TUnit.Core/Data/IDisposableReference.cs +++ /dev/null @@ -1,43 +0,0 @@ -namespace TUnit.Core.Data; - -/// -/// Represents a disposable reference to an object managed by a scope manager. -/// -internal interface IDisposableReference -{ - /// - /// Attempts to dispose the referenced object. - /// - /// A task representing the disposal operation. - Task DisposeAsync(); -} - -/// -/// A concrete implementation of a disposable reference. -/// -/// The type of the referenced object. -internal class ScopedReference : IDisposableReference -{ - private readonly T _instance; - private readonly IScopeManager _scopeManager; - - /// - /// Initializes a new instance of the class. - /// - /// The referenced instance. - /// The scope manager responsible for disposal. - public ScopedReference(T instance, IScopeManager scopeManager) - { - _instance = instance; - _scopeManager = scopeManager ?? throw new ArgumentNullException(nameof(scopeManager)); - } - - /// - /// Attempts to dispose the referenced object through its scope manager. - /// - /// A task representing the disposal operation. - public async Task DisposeAsync() - { - await _scopeManager.TryDisposeAsync(_instance); - } -} diff --git a/TUnit.Core/Data/IScopeManager.cs b/TUnit.Core/Data/IScopeManager.cs deleted file mode 100644 index 6fd275dbc5..0000000000 --- a/TUnit.Core/Data/IScopeManager.cs +++ /dev/null @@ -1,29 +0,0 @@ -namespace TUnit.Core.Data; - -/// -/// Interface for managing object disposal in a specific scope. -/// -internal interface IScopeManager -{ - /// - /// Gets or creates an instance of the specified type. - /// - /// The type of object to get or create. - /// The factory function to create the instance if it doesn't exist. - /// The instance. - T GetOrCreate(Func factory); - - /// - /// Increments the usage count for the specified type. - /// - /// The type to increment usage for. - void IncrementUsage(); - - /// - /// Attempts to dispose an instance of the specified type. - /// - /// The type of object to dispose. - /// The item to dispose. - /// True if the item was disposed; false if it's still in use. - Task TryDisposeAsync(T item); -} diff --git a/TUnit.Core/Data/ScopedContainer.cs b/TUnit.Core/Data/ScopedContainer.cs deleted file mode 100644 index b0a4a801d5..0000000000 --- a/TUnit.Core/Data/ScopedContainer.cs +++ /dev/null @@ -1,37 +0,0 @@ -namespace TUnit.Core.Data; - -/// -/// A unified container for managing scoped test data instances. -/// -/// The type of the scoping key (e.g., Type for class scope, Assembly for assembly scope). -internal class ScopedContainer where TKey : notnull -{ - private readonly GetOnlyDictionary> _containers = new(); - - /// - /// Gets or creates an instance for the specified key and type. - /// - /// The scoping key. - /// The type of object to retrieve or create. - /// The factory function to create the instance if it doesn't exist. - /// The instance. - public object GetOrCreate(TKey key, Type type, Func factory) - { - var container = _containers.GetOrAdd(key, _ => new GetOnlyDictionary()); - return container.GetOrAdd(type, _ => factory()); - } - - /// - /// Attempts to get an existing instance for the specified key and type. - /// - /// The scoping key. - /// The type of object to retrieve. - /// The instance if found. - /// True if the instance was found; otherwise, false. - public bool TryGet(TKey key, Type type, out object? instance) - { - instance = null; - return _containers.TryGetValue(key, out var container) && - container.TryGetValue(type, out instance); - } -} diff --git a/TUnit.Core/Data/ScopedDictionary.cs b/TUnit.Core/Data/ScopedDictionary.cs new file mode 100644 index 0000000000..4e788fe0bf --- /dev/null +++ b/TUnit.Core/Data/ScopedDictionary.cs @@ -0,0 +1,34 @@ +using TUnit.Core.Tracking; + +namespace TUnit.Core.Data; + +public class ScopedDictionary + where TScope : notnull +{ + private readonly GetOnlyDictionary> _scopedContainers = new(); + + public object? GetOrCreate(TScope scope, Type type, Func factory) + { + var innerDictionary = _scopedContainers.GetOrAdd(scope, _ => new GetOnlyDictionary()); + + var obj = innerDictionary.GetOrAdd(type, factory); + + ObjectTracker.OnDisposed(obj, () => + { + innerDictionary.Remove(type); + }); + + return obj; + } + + /// + /// Removes a specific value from all scopes and types where it might be stored. + /// This is used to clear disposed objects from the cache. + /// + public void RemoveValue(object valueToRemove) + { + // Since GetOnlyDictionary doesn't support removal, we'll need to track this differently + // For now, log that this needs to be implemented + Console.WriteLine($"[ScopedDictionary] WARNING: RemoveValue called for {valueToRemove.GetType().Name}@{valueToRemove.GetHashCode():X8} but GetOnlyDictionary doesn't support removal"); + } +} diff --git a/TUnit.Core/DataGeneratorMetadataCreator.cs b/TUnit.Core/DataGeneratorMetadataCreator.cs index 1d58b4bbd5..c63bd4f515 100644 --- a/TUnit.Core/DataGeneratorMetadataCreator.cs +++ b/TUnit.Core/DataGeneratorMetadataCreator.cs @@ -101,7 +101,9 @@ public static DataGeneratorMetadata CreateForReflectionDiscovery( { TestBuilderContext = new TestBuilderContextAccessor(new TestBuilderContext { - TestMetadata = null! // Not available during discovery + TestMetadata = null!, // Not available during discovery + Events = new TestContextEvents(), + ObjectBag = new Dictionary() }), MembersToGenerate = membersToGenerate, TestInformation = methodMetadata, @@ -154,7 +156,9 @@ public static DataGeneratorMetadata CreateForGenericTypeDiscovery( TestBuilderContext = new TestBuilderContextAccessor(new TestBuilderContext { TestMetadata = discoveryMethodMetadata, - DataSourceAttribute = dataSource + DataSourceAttribute = dataSource, + Events = new TestContextEvents(), + ObjectBag = new Dictionary() }), MembersToGenerate = [dummyParameter], TestInformation = discoveryMethodMetadata, @@ -187,11 +191,11 @@ public static DataGeneratorMetadata CreateForPropertyInjection( DataSourceAttribute = dataSource, ObjectBag = objectBag ?? [] } - : null; + : TestSessionContext.GlobalStaticPropertyContext; return new DataGeneratorMetadata { - TestBuilderContext = testBuilderContext != null ? new TestBuilderContextAccessor(testBuilderContext) : null, + TestBuilderContext = new TestBuilderContextAccessor(testBuilderContext), MembersToGenerate = [propertyMetadata], TestInformation = methodMetadata, Type = DataGeneratorType.Property, diff --git a/TUnit.Core/Helpers/Disposer.cs b/TUnit.Core/Helpers/Disposer.cs index 8398682fda..772b49de07 100644 --- a/TUnit.Core/Helpers/Disposer.cs +++ b/TUnit.Core/Helpers/Disposer.cs @@ -10,7 +10,7 @@ public async ValueTask DisposeAsync(object? obj) { if (obj is IAsyncDisposable asyncDisposable) { - await asyncDisposable.DisposeAsync(); + await asyncDisposable.DisposeAsync().ConfigureAwait(false); } else if (obj is IDisposable disposable) { diff --git a/TUnit.Core/Helpers/TestClassTypeHelper.cs b/TUnit.Core/Helpers/TestClassTypeHelper.cs index c2bba084c5..a0f4336582 100644 --- a/TUnit.Core/Helpers/TestClassTypeHelper.cs +++ b/TUnit.Core/Helpers/TestClassTypeHelper.cs @@ -12,10 +12,10 @@ public static class TestClassTypeHelper /// /// The data generator metadata /// The test class type, or null if it cannot be determined - public static Type? GetTestClassType(DataGeneratorMetadata dataGeneratorMetadata) + public static Type GetTestClassType(DataGeneratorMetadata dataGeneratorMetadata) { // Try to get from TestInformation first (primary) - if (dataGeneratorMetadata.TestInformation?.Class?.Type != null) + if (dataGeneratorMetadata.TestInformation?.Class.Type != null) { return dataGeneratorMetadata.TestInformation.Class.Type; } @@ -29,6 +29,6 @@ public static class TestClassTypeHelper } } - return null; + return typeof(object); } -} \ No newline at end of file +} diff --git a/TUnit.Core/Models/DataGeneratorMetadata.cs b/TUnit.Core/Models/DataGeneratorMetadata.cs index 954cb536c3..50eded9d7f 100644 --- a/TUnit.Core/Models/DataGeneratorMetadata.cs +++ b/TUnit.Core/Models/DataGeneratorMetadata.cs @@ -5,7 +5,7 @@ namespace TUnit.Core; public record DataGeneratorMetadata { - public required TestBuilderContextAccessor? TestBuilderContext { get; init; } + public required TestBuilderContextAccessor TestBuilderContext { get; init; } public required MemberMetadata[] MembersToGenerate { get; init; } public required MethodMetadata? TestInformation { get; init; } public required DataGeneratorType Type { get; init; } diff --git a/TUnit.Core/Models/GlobalContext.cs b/TUnit.Core/Models/GlobalContext.cs index 758132f3e5..00562e3474 100644 --- a/TUnit.Core/Models/GlobalContext.cs +++ b/TUnit.Core/Models/GlobalContext.cs @@ -1,3 +1,5 @@ +using System.Diagnostics.CodeAnalysis; +using TUnit.Core.Helpers; using TUnit.Core.Logging; namespace TUnit.Core; @@ -24,6 +26,13 @@ internal GlobalContext() : base(null) public TextWriter OriginalConsoleOut { get; set; } = Console.Out; public TextWriter OriginalConsoleError { get; set; } = Console.Error; + [field: AllowNull, MaybeNull] + internal Disposer Disposer + { + get => field ??= new Disposer(GlobalLogger); + set; + } + internal override void RestoreContextAsyncLocal() { Current = this; diff --git a/TUnit.Core/Models/TestSessionContext.cs b/TUnit.Core/Models/TestSessionContext.cs index a94d485d78..d844696fb0 100644 --- a/TUnit.Core/Models/TestSessionContext.cs +++ b/TUnit.Core/Models/TestSessionContext.cs @@ -9,6 +9,39 @@ public class TestSessionContext : Context internal set => Contexts.Value = value; } + /// + /// Global TestBuilderContext for static property initialization. + /// This context lives for the entire test session and is used for tracking + /// disposable objects created during static property initialization. + /// Initialized immediately as a static field to be available before TestSessionContext creation. + /// + public static TestBuilderContext GlobalStaticPropertyContext { get; } = new TestBuilderContext + { + TestMetadata = new MethodMetadata + { + Type = typeof(object), + Name = "StaticPropertyInitialization", + TypeReference = TypeReference.CreateConcrete(typeof(object).AssemblyQualifiedName ?? "System.Object"), + ReturnTypeReference = TypeReference.CreateConcrete(typeof(void).AssemblyQualifiedName ?? "System.Void"), + Parameters = Array.Empty(), + GenericTypeCount = 0, + Class = new ClassMetadata + { + Name = "GlobalStaticPropertyInitializer", + Type = typeof(object), + Namespace = "TUnit.Core", + TypeReference = TypeReference.CreateConcrete(typeof(object).AssemblyQualifiedName ?? "System.Object"), + Assembly = AssemblyMetadata.GetOrAdd("TUnit.Core", () => new AssemblyMetadata { Name = "TUnit.Core" }), + Properties = Array.Empty(), + Parameters = Array.Empty(), + Parent = null + } + }, + Events = new TestContextEvents(), + ObjectBag = new Dictionary(), + DataSourceAttribute = null + }; + internal TestSessionContext(TestDiscoveryContext beforeTestDiscoveryContext) : base(beforeTestDiscoveryContext) { Current = this; diff --git a/TUnit.Core/PropertyInjectionService.cs b/TUnit.Core/PropertyInjectionService.cs index ccb4c48091..db25e6d9d9 100644 --- a/TUnit.Core/PropertyInjectionService.cs +++ b/TUnit.Core/PropertyInjectionService.cs @@ -56,7 +56,7 @@ public static async Task InjectPropertiesIntoArgumentsAsync(object?[] arguments, } /// - /// Determines if an object should have properties injected based on its type and whether it has nested data sources. + /// Determines if an object should have properties injected based on whether it has properties with data source attributes. /// private static bool ShouldInjectProperties(object? obj) { @@ -67,25 +67,11 @@ private static bool ShouldInjectProperties(object? obj) var type = obj.GetType(); - // Use cached result for better performance - return _shouldInjectCache.GetOrAdd(type, _ => + // Use cached result for better performance - check if the type has injectable properties + return _shouldInjectCache.GetOrAdd(type, t => { - if (type.IsPrimitive || type == typeof(string) || type.IsEnum || type.IsValueType) - { - return false; - } - - if (type.IsArray || typeof(System.Collections.IEnumerable).IsAssignableFrom(type)) - { - return false; - } - - if (type.Assembly == typeof(object).Assembly) - { - return false; - } - - return true; + var plan = GetOrCreateInjectionPlan(t); + return plan.HasProperties; }); } @@ -94,13 +80,30 @@ private static bool ShouldInjectProperties(object? obj) /// Uses source generation mode when available, falls back to reflection mode. /// After injection, handles tracking, initialization, and recursive injection. /// - public static async Task InjectPropertiesIntoObjectAsync(object instance, Dictionary? objectBag, MethodMetadata? methodMetadata, TestContextEvents? events) + public static Task InjectPropertiesIntoObjectAsync(object instance, Dictionary? objectBag, MethodMetadata? methodMetadata, TestContextEvents? events) + { + // Start with an empty visited set for cycle detection +#if NETSTANDARD2_0 + var visitedObjects = new HashSet(); +#else + var visitedObjects = new HashSet(System.Collections.Generic.ReferenceEqualityComparer.Instance); +#endif + return InjectPropertiesIntoObjectAsyncCore(instance, objectBag, methodMetadata, events, visitedObjects); + } + + private static async Task InjectPropertiesIntoObjectAsyncCore(object instance, Dictionary? objectBag, MethodMetadata? methodMetadata, TestContextEvents? events, HashSet visitedObjects) { if (instance == null) { return; } + // Prevent cycles - if we're already processing this object, skip it + if (!visitedObjects.Add(instance)) + { + return; + } + // If we don't have the required context, try to get it from the current test context objectBag ??= TestContext.Current?.ObjectBag ?? new Dictionary(); methodMetadata ??= TestContext.Current?.TestDetails?.MethodMetadata; @@ -111,29 +114,79 @@ public static async Task InjectPropertiesIntoObjectAsync(object instance, Dictio try { - await _injectionTasks.GetOrAdd(instance, async _ => + bool alreadyProcessed = _injectionTasks.TryGetValue(instance, out var existingTask); + + if (alreadyProcessed && existingTask != null) { - var plan = GetOrCreateInjectionPlan(instance.GetType()); - - // Fast path: skip if no properties to inject - if (!plan.HasProperties) - { - await ObjectInitializer.InitializeAsync(instance); - return; - } + await existingTask; - if (SourceRegistrar.IsEnabled) + var plan = GetOrCreateInjectionPlan(instance.GetType()); + if (plan.HasProperties) { - await InjectPropertiesUsingPlanAsync(instance, plan.SourceGeneratedProperties, objectBag, methodMetadata, events); + if (SourceRegistrar.IsEnabled) + { + foreach (var metadata in plan.SourceGeneratedProperties) + { + var property = metadata.ContainingType.GetProperty(metadata.PropertyName); + if (property != null && property.CanRead) + { + var propertyValue = property.GetValue(instance); + if (propertyValue != null) + { + ObjectTracker.TrackObject(events, propertyValue); + ObjectTracker.TrackOwnership(instance, propertyValue); + + if (ShouldInjectProperties(propertyValue)) + { + await InjectPropertiesIntoObjectAsyncCore(propertyValue, objectBag, methodMetadata, events, visitedObjects); + } + } + } + } + } + else + { + foreach (var (property, _) in plan.ReflectionProperties) + { + var propertyValue = property.GetValue(instance); + if (propertyValue != null) + { + ObjectTracker.TrackObject(events, propertyValue); + ObjectTracker.TrackOwnership(instance, propertyValue); + + if (ShouldInjectProperties(propertyValue)) + { + await InjectPropertiesIntoObjectAsyncCore(propertyValue, objectBag, methodMetadata, events, visitedObjects); + } + } + } + } } - else + } + else + { + await _injectionTasks.GetOrAdd(instance, async _ => { - await InjectPropertiesUsingReflectionPlanAsync(instance, plan.ReflectionProperties, objectBag, methodMetadata, events); - } - - // Initialize the object AFTER all its properties have been injected and initialized - await ObjectInitializer.InitializeAsync(instance); - }); + var plan = GetOrCreateInjectionPlan(instance.GetType()); + + if (!plan.HasProperties) + { + await ObjectInitializer.InitializeAsync(instance); + return; + } + + if (SourceRegistrar.IsEnabled) + { + await InjectPropertiesUsingPlanAsync(instance, plan.SourceGeneratedProperties, objectBag, methodMetadata, events, visitedObjects); + } + else + { + await InjectPropertiesUsingReflectionPlanAsync(instance, plan.ReflectionProperties, objectBag, methodMetadata, events, visitedObjects); + } + + await ObjectInitializer.InitializeAsync(instance); + }); + } } catch (Exception ex) { @@ -196,7 +249,7 @@ private static PropertyInjectionPlan GetOrCreateInjectionPlan(Type type) /// /// Injects properties using a cached source-generated plan. /// - private static async Task InjectPropertiesUsingPlanAsync(object instance, PropertyInjectionMetadata[] properties, Dictionary objectBag, MethodMetadata? methodMetadata, TestContextEvents events) + private static async Task InjectPropertiesUsingPlanAsync(object instance, PropertyInjectionMetadata[] properties, Dictionary objectBag, MethodMetadata? methodMetadata, TestContextEvents events, HashSet visitedObjects) { if (properties.Length == 0) { @@ -205,7 +258,7 @@ private static async Task InjectPropertiesUsingPlanAsync(object instance, Proper // Process all properties at the same level in parallel var propertyTasks = properties.Select(metadata => - ProcessPropertyMetadata(instance, metadata, objectBag, methodMetadata, events, TestContext.Current) + ProcessPropertyMetadata(instance, metadata, objectBag, methodMetadata, events, visitedObjects, TestContext.Current) ).ToArray(); await Task.WhenAll(propertyTasks); @@ -215,7 +268,7 @@ private static async Task InjectPropertiesUsingPlanAsync(object instance, Proper /// Injects properties using a cached reflection plan. /// [UnconditionalSuppressMessage("Trimming", "IL2075:\'this\' argument does not satisfy \'DynamicallyAccessedMembersAttribute\' in call to target method. The return value of the source method does not have matching annotations.")] - private static async Task InjectPropertiesUsingReflectionPlanAsync(object instance, (PropertyInfo Property, IDataSourceAttribute DataSource)[] properties, Dictionary objectBag, MethodMetadata? methodMetadata, TestContextEvents events) + private static async Task InjectPropertiesUsingReflectionPlanAsync(object instance, (PropertyInfo Property, IDataSourceAttribute DataSource)[] properties, Dictionary objectBag, MethodMetadata? methodMetadata, TestContextEvents events, HashSet visitedObjects) { if (properties.Length == 0) { @@ -224,7 +277,7 @@ private static async Task InjectPropertiesUsingReflectionPlanAsync(object instan // Process all properties in parallel var propertyTasks = properties.Select(pair => - ProcessReflectionPropertyDataSource(instance, pair.Property, pair.DataSource, objectBag, methodMetadata, events, TestContext.Current) + ProcessReflectionPropertyDataSource(instance, pair.Property, pair.DataSource, objectBag, methodMetadata, events, visitedObjects, TestContext.Current) ).ToArray(); await Task.WhenAll(propertyTasks); @@ -235,7 +288,7 @@ private static async Task InjectPropertiesUsingReflectionPlanAsync(object instan /// [UnconditionalSuppressMessage("Trimming", "IL2072:Target parameter argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The return value of the source method does not have matching annotations.")] private static async Task ProcessPropertyMetadata(object instance, PropertyInjectionMetadata metadata, Dictionary objectBag, MethodMetadata? methodMetadata, - TestContextEvents events, TestContext? testContext = null) + TestContextEvents events, HashSet visitedObjects, TestContext? testContext = null) { var dataSource = metadata.CreateDataSource(); var propertyMetadata = new PropertyMetadata @@ -271,7 +324,12 @@ private static async Task ProcessPropertyMetadata(object instance, PropertyInjec if (value != null) { - await ProcessInjectedPropertyValue(instance, value, metadata.SetProperty, objectBag, methodMetadata, events); + await ProcessInjectedPropertyValue(instance, value, metadata.SetProperty, objectBag, methodMetadata, events, visitedObjects); + // Add to TestClassInjectedPropertyArguments for tracking + if (testContext != null) + { + testContext.TestDetails.TestClassInjectedPropertyArguments[metadata.PropertyName] = value; + } break; // Only use first value } } @@ -281,7 +339,7 @@ private static async Task ProcessPropertyMetadata(object instance, PropertyInjec /// Processes a property data source using reflection mode. /// [UnconditionalSuppressMessage("Trimming", "IL2072:Target parameter argument does not satisfy \'DynamicallyAccessedMembersAttribute\' in call to target method. The return value of the source method does not have matching annotations.")] - private static async Task ProcessReflectionPropertyDataSource(object instance, PropertyInfo property, IDataSourceAttribute dataSource, Dictionary objectBag, MethodMetadata? methodMetadata, TestContextEvents events, TestContext? testContext = null) + private static async Task ProcessReflectionPropertyDataSource(object instance, PropertyInfo property, IDataSourceAttribute dataSource, Dictionary objectBag, MethodMetadata? methodMetadata, TestContextEvents events, HashSet visitedObjects, TestContext? testContext = null) { // Use centralized factory for reflection mode var dataGeneratorMetadata = DataGeneratorMetadataCreator.CreateForPropertyInjection( @@ -307,16 +365,21 @@ private static async Task ProcessReflectionPropertyDataSource(object instance, P if (value != null) { var setter = CreatePropertySetter(property); - await ProcessInjectedPropertyValue(instance, value, setter, objectBag, methodMetadata, events); + await ProcessInjectedPropertyValue(instance, value, setter, objectBag, methodMetadata, events, visitedObjects); + // Add to TestClassInjectedPropertyArguments for tracking + if (testContext != null) + { + testContext.TestDetails.TestClassInjectedPropertyArguments[property.Name] = value; + } break; // Only use first value } } } /// - /// Processes a single injected property value: tracks it, initializes it, sets it on the instance, and handles cleanup. + /// Processes a single injected property value: tracks it, initializes it, sets it on the instance. /// - private static async Task ProcessInjectedPropertyValue(object instance, object? propertyValue, Action setProperty, Dictionary objectBag, MethodMetadata? methodMetadata, TestContextEvents events) + private static async Task ProcessInjectedPropertyValue(object instance, object? propertyValue, Action setProperty, Dictionary objectBag, MethodMetadata? methodMetadata, TestContextEvents events, HashSet visitedObjects) { if (propertyValue == null) { @@ -324,20 +387,17 @@ private static async Task ProcessInjectedPropertyValue(object instance, object? } ObjectTracker.TrackObject(events, propertyValue); + ObjectTracker.TrackOwnership(instance, propertyValue); - // First, recursively inject and initialize all descendants of this property value if (ShouldInjectProperties(propertyValue)) { - // This will recursively inject properties and initialize the object - await InjectPropertiesIntoObjectAsync(propertyValue, objectBag, methodMetadata, events); + await InjectPropertiesIntoObjectAsyncCore(propertyValue, objectBag, methodMetadata, events, visitedObjects); } else { - // For objects that don't need property injection, still initialize them await ObjectInitializer.InitializeAsync(propertyValue); } - // Finally, set the fully initialized property on the parent setProperty(instance, propertyValue); } @@ -531,7 +591,16 @@ public static async Task InjectPropertiesAsync( if (value != null) { // Use the modern service for recursive injection and initialization - await ProcessInjectedPropertyValue(instance, value, propertyInjection.Setter, objectBag, testInformation, testContext.Events); + // Create a new visited set for this legacy call +#if NETSTANDARD2_0 + var visitedObjects = new HashSet(); +#else + var visitedObjects = new HashSet(System.Collections.Generic.ReferenceEqualityComparer.Instance); +#endif + visitedObjects.Add(instance); // Add the current instance to prevent re-processing + await ProcessInjectedPropertyValue(instance, value, propertyInjection.Setter, objectBag, testInformation, testContext.Events, visitedObjects); + // Add to TestClassInjectedPropertyArguments for tracking + testContext.TestDetails.TestClassInjectedPropertyArguments[propertyInjection.PropertyName] = value; break; // Only use first value } } diff --git a/TUnit.Core/ReferenceTracking/DataSourceReferenceExtensions.cs b/TUnit.Core/ReferenceTracking/DataSourceReferenceExtensions.cs deleted file mode 100644 index 7622a520f4..0000000000 --- a/TUnit.Core/ReferenceTracking/DataSourceReferenceExtensions.cs +++ /dev/null @@ -1,34 +0,0 @@ -using TUnit.Core.Tracking; - -namespace TUnit.Core.ReferenceTracking; - -public static class DataSourceReferenceExtensions -{ - /// - /// Tracks all data source objects in the current test context. - /// Call this at the beginning of your test if you want to manually manage references. - /// - public static void TrackMethodArguments(this TestContext context) - { - foreach (var arg in context.TestDetails.TestMethodArguments) - { - ObjectTracker.TrackObject(context.Events, arg); - } - } - - public static void TrackClassArguments(this TestContext context) - { - foreach (var arg in context.TestDetails.TestClassArguments) - { - ObjectTracker.TrackObject(context.Events, arg); - } - } - - public static void TrackPropertyArguments(this TestContext context) - { - foreach (var kvp in context.TestDetails.TestClassInjectedPropertyArguments) - { - ObjectTracker.TrackObject(context.Events, kvp.Value); - } - } -} diff --git a/TUnit.Core/ResettableLazy.cs b/TUnit.Core/ResettableLazy.cs index 092261a922..23e99eb89f 100644 --- a/TUnit.Core/ResettableLazy.cs +++ b/TUnit.Core/ResettableLazy.cs @@ -65,7 +65,7 @@ public ResettableLazy(Func> factory, string sessionId, TestBuilderContex public virtual async ValueTask ResetLazy() { - await DisposeAsync(); + await DisposeAsync().ConfigureAwait(false); _lazy = new Lazy>(_factory); } @@ -74,8 +74,8 @@ public async ValueTask DisposeAsync() { if (_lazy.IsValueCreated) { - var instance = await _lazy.Value; - await DisposeAsync(instance); + var instance = await _lazy.Value.ConfigureAwait(false); + await DisposeAsync(instance).ConfigureAwait(false); } } @@ -83,7 +83,7 @@ protected static async ValueTask DisposeAsync(object? obj) { if (obj is IAsyncDisposable asyncDisposable) { - await asyncDisposable.DisposeAsync(); + await asyncDisposable.DisposeAsync().ConfigureAwait(false); } else if (obj is IDisposable disposable) { diff --git a/TUnit.Core/StaticPropertyReflectionInitializer.cs b/TUnit.Core/StaticPropertyReflectionInitializer.cs index ed9943f84a..72bcca272c 100644 --- a/TUnit.Core/StaticPropertyReflectionInitializer.cs +++ b/TUnit.Core/StaticPropertyReflectionInitializer.cs @@ -98,7 +98,9 @@ private static async Task InitializeStaticProperty(Type type, PropertyInfo prope TestBuilderContext = new TestBuilderContextAccessor(new TestBuilderContext() { DataSourceAttribute = dataSourceAttr, - TestMetadata = null! + TestMetadata = null!, + Events = new TestContextEvents(), + ObjectBag = new Dictionary() }), MembersToGenerate = [], TestInformation = null!, diff --git a/TUnit.Core/TestContextEvents.cs b/TUnit.Core/TestContextEvents.cs index 18e6c62e7c..48c122a99e 100644 --- a/TUnit.Core/TestContextEvents.cs +++ b/TUnit.Core/TestContextEvents.cs @@ -3,7 +3,7 @@ namespace TUnit.Core; /// /// Simplified test context events /// -public record TestContextEvents +public class TestContextEvents { public AsyncEvent? OnDispose { get; set; } public AsyncEvent? OnTestRegistered { get; set; } diff --git a/TUnit.Core/TestDataContainer.cs b/TUnit.Core/TestDataContainer.cs index 36be2bdd8a..49636ca0dd 100644 --- a/TUnit.Core/TestDataContainer.cs +++ b/TUnit.Core/TestDataContainer.cs @@ -5,27 +5,27 @@ namespace TUnit.Core; internal static class TestDataContainer { - private static readonly ScopedContainer _globalContainer = new(); - private static readonly ScopedContainer _classContainer = new(); - private static readonly ScopedContainer _assemblyContainer = new(); - private static readonly ScopedContainer _keyContainer = new(); + private static readonly ScopedDictionary _globalContainer = new(); + private static readonly ScopedDictionary _classContainer = new(); + private static readonly ScopedDictionary _assemblyContainer = new(); + private static readonly ScopedDictionary _keyContainer = new(); - public static object GetInstanceForClass(Type testClass, Type type, Func func) + public static object? GetInstanceForClass(Type testClass, Type type, Func func) { return _classContainer.GetOrCreate(testClass, type, func); } - public static object GetInstanceForAssembly(Assembly assembly, Type type, Func func) + public static object? GetInstanceForAssembly(Assembly assembly, Type type, Func func) { return _assemblyContainer.GetOrCreate(assembly, type, func); } - public static object GetGlobalInstance(Type type, Func func) + public static object? GetGlobalInstance(Type type, Func func) { return _globalContainer.GetOrCreate(typeof(object).FullName!, type, func); } - public static object GetInstanceForKey(string key, Type type, Func func) + public static object? GetInstanceForKey(string key, Type type, Func func) { return _keyContainer.GetOrCreate(key, type, func); } diff --git a/TUnit.Core/Tracking/ObjectTracker.cs b/TUnit.Core/Tracking/ObjectTracker.cs index b99481ef4e..01bd5ce28b 100644 --- a/TUnit.Core/Tracking/ObjectTracker.cs +++ b/TUnit.Core/Tracking/ObjectTracker.cs @@ -4,130 +4,134 @@ namespace TUnit.Core.Tracking; /// -/// Unified static object tracker that combines reference counting with lifecycle management. -/// Consolidates the functionality of both DataSourceReferenceTracker and ActiveObjectTracker. +/// Pure reference counting object tracker for disposable objects. +/// Objects are disposed when their reference count reaches zero, regardless of sharing type. /// -internal static class ObjectTracker +public static class ObjectTracker { private static readonly ConcurrentDictionary _trackedObjects = new(); + private static readonly ConcurrentDictionary> _ownedObjects = new(); /// - /// Tracks an object and increments its reference count. + /// Tracks multiple objects for a test context and registers a single disposal handler. + /// Each object's reference count is incremented once. /// /// Events for the test instance - /// The object to track - /// The tracked object (same instance) - public static void TrackObject(TestContextEvents events, object? obj) + /// The objects to track (constructor args, method args, injected properties) + internal static void TrackObjectsForContext(TestContextEvents events, IEnumerable objects) { - if (obj == null || ShouldSkipTracking(obj)) + foreach (var obj in objects) { - return; + TrackObject(events, obj); } - - var counter = _trackedObjects.GetOrAdd(obj, _ => new Counter()); - - counter.Increment(); - - events.OnDispose += async (_, _) => - { - await ReleaseObject(obj); - }; } /// - /// Decrements the reference count for an object and optionally disposes it. + /// Tracks a single object for a test context. + /// For backward compatibility - adds to existing tracked objects for the context. /// - /// The object to release - /// True if the object has no more references and was removed from tracking - private static async Task ReleaseObject(object? obj) + /// Events for the test instance + /// The object to track + internal static void TrackObject(TestContextEvents events, object? obj) { - if (obj == null) + if (obj == null || ShouldSkipTracking(obj)) { return; } - if (!_trackedObjects.TryGetValue(obj, out var counter)) - { - return; - } + var counter = _trackedObjects.GetOrAdd(obj, _ => new Counter()); + + counter.Increment(); - var count = counter.Decrement(); + var objType = obj.GetType().Name; - if (count <= 0) + events.OnDispose += async (_, _) => { - _trackedObjects.TryRemove(obj, out _); + var count = counter.Decrement(); - if (obj is IAsyncDisposable asyncDisposable) + if (count < 0) { - await asyncDisposable.DisposeAsync(); + throw new InvalidOperationException($"Reference count for object {objType} went below zero. This indicates a bug in the reference counting logic."); } - else if (obj is IDisposable disposable) + + if (count == 0) { - disposable.Dispose(); + await GlobalContext.Current.Disposer.DisposeAsync(obj); } - } + }; } /// - /// Gets the reference counter for a tracked object. + /// Determines if an object should be skipped from tracking. /// - /// The object to get counter for - /// Counter or null if not tracked - public static Counter? GetReferenceInfo(object? obj) + private static bool ShouldSkipTracking(object? obj) { - return obj != null && _trackedObjects.TryGetValue(obj, out var counter) ? counter : null; + return obj is not IDisposable and not IAsyncDisposable; } - /// - /// Tries to get the reference counter for an object. - /// - /// The object to check - /// The reference counter if found - /// True if the object is tracked - public static bool TryGetReference(object? obj, out Counter? counter) + public static void OnDisposed(object? o, Action action) { - counter = null; - if (obj == null || ShouldSkipTracking(obj)) + if(o is not IDisposable and not IAsyncDisposable) { - return false; + return; } - return _trackedObjects.TryGetValue(obj, out counter); + _trackedObjects.GetOrAdd(o, _ => new Counter()) + .OnCountChanged += (_, count) => + { + if (count == 0) + { + action(); + } + }; } - + /// - /// Removes an object from tracking without decrementing its count. + /// Tracks that an owner object owns another object. + /// This increments the owned object's reference count. + /// When the owner is disposed, it will decrement the owned object's reference count. /// - /// The object to remove - /// True if the object was removed - public static bool RemoveObject(object? obj) + public static void TrackOwnership(object owner, object owned) { - if (obj == null) + if (owner == null || owned == null || ShouldSkipTracking(owned)) { - return false; + return; + } + + var ownedSet = _ownedObjects.GetOrAdd(owner, _ => new HashSet()); + lock (ownedSet) + { + if (ownedSet.Add(owned)) + { + var counter = _trackedObjects.GetOrAdd(owned, _ => new Counter()); + counter.Increment(); + + OnDisposed(owner, () => + { + ReleaseOwnedObjects(owner); + }); + } } - - return _trackedObjects.TryRemove(obj, out _); - } - - /// - /// Gets the count of currently tracked objects. - /// - public static int TrackedObjectCount => _trackedObjects.Count; - - /// - /// Clears all tracked references. Use with caution! - /// - public static void Clear() - { - _trackedObjects.Clear(); } - - /// - /// Determines if an object should be skipped from tracking. - /// - private static bool ShouldSkipTracking(object? obj) + + private static void ReleaseOwnedObjects(object owner) { - return obj is not IDisposable and not IAsyncDisposable; + if (_ownedObjects.TryRemove(owner, out var ownedSet)) + { + lock (ownedSet) + { + foreach (var owned in ownedSet) + { + if (_trackedObjects.TryGetValue(owned, out var counter)) + { + var count = counter.Decrement(); + if (count == 0) + { + _ = GlobalContext.Current.Disposer.DisposeAsync(owned); + } + } + } + } + } } } diff --git a/TUnit.Engine/Building/TestBuilder.cs b/TUnit.Engine/Building/TestBuilder.cs index 259f2615fb..527e54d41f 100644 --- a/TUnit.Engine/Building/TestBuilder.cs +++ b/TUnit.Engine/Building/TestBuilder.cs @@ -26,6 +26,12 @@ public TestBuilder(string sessionId, EventReceiverOrchestrator eventReceiverOrch private async Task CreateInstance(TestMetadata metadata, Type[] resolvedClassGenericArgs, object?[] classData, TestBuilderContext? builderContext = null) { + // If no builderContext provided and we're in test execution phase, create one from current TestContext + if (builderContext == null && TestContext.Current != null) + { + builderContext = TestBuilderContext.FromTestContext(TestContext.Current, null); + } + // First try to create instance with ClassConstructor attribute var attributes = metadata.AttributeFactory(); @@ -99,11 +105,6 @@ public async Task> BuildTestsFromMetadataAsy var repeatAttr = filteredAttributes.OfType().FirstOrDefault(); var repeatCount = repeatAttr?.Times ?? 0; - var contextAccessor = new TestBuilderContextAccessor(new TestBuilderContext - { - TestMetadata = metadata.MethodMetadata - }); - if (metadata.ClassDataSources.Any(ds => ds is IAccessesInstanceData)) { var failedTest = await CreateFailedTestForClassDataSourceCircularDependency(metadata); @@ -111,6 +112,14 @@ public async Task> BuildTestsFromMetadataAsy return tests; } + // Create a single context accessor that we'll reuse, updating its Current property for each test + var contextAccessor = new TestBuilderContextAccessor(new TestBuilderContext + { + TestMetadata = metadata.MethodMetadata, + Events = new TestContextEvents(), + ObjectBag = new Dictionary() + }); + var classDataAttributeIndex = 0; foreach (var classDataSource in GetDataSources(metadata.ClassDataSources)) { @@ -203,6 +212,14 @@ public async Task> BuildTestsFromMetadataAsy for (var i = 0; i < repeatCount + 1; i++) { + // Update context BEFORE calling data factories so they track objects in the right context + contextAccessor.Current = new TestBuilderContext + { + TestMetadata = metadata.MethodMetadata, + Events = new TestContextEvents(), + ObjectBag = new Dictionary() + }; + classData = DataUnwrapper.Unwrap(await classDataFactory() ?? []); var methodData = DataUnwrapper.Unwrap(await methodDataFactory() ?? []); @@ -285,8 +302,8 @@ public async Task> BuildTestsFromMetadataAsy var capturedMetadata = metadata; var capturedClassGenericArgs = resolvedClassGenericArgs; var capturedClassData = classData; - var capturedContext = contextAccessor.Current; - instanceFactory = () => CreateInstance(capturedMetadata, capturedClassGenericArgs, capturedClassData, capturedContext); + // Pass null for builderContext - CreateInstance will use TestContext.Current during execution + instanceFactory = () => CreateInstance(capturedMetadata, capturedClassGenericArgs, capturedClassData, null); } var testData = new TestData @@ -312,11 +329,8 @@ public async Task> BuildTestsFromMetadataAsy test.Context.SkipReason = basicSkipReason; } tests.Add(test); - - contextAccessor.Current = new TestBuilderContext - { - TestMetadata = metadata.MethodMetadata - }; + + // Context already updated at the beginning of the loop before calling factories } } } @@ -717,15 +731,9 @@ private TestContext CreateFailedTestContext(TestMetadata metadata, TestDetails t private static void TrackDataSourceObjects(TestContext context, object?[] classArguments, object?[] methodArguments) { - foreach (var arg in classArguments) - { - ObjectTracker.TrackObject(context.Events, arg); - } - - foreach (var arg in methodArguments) - { - ObjectTracker.TrackObject(context.Events, arg); - } + // Track all objects at once with a single disposal handler + var allObjects = classArguments.Concat(methodArguments); + ObjectTracker.TrackObjectsForContext(context.Events, allObjects); } private async Task CreateFailedTestForInstanceDataSourceError(TestMetadata metadata, Exception exception) @@ -995,7 +1003,9 @@ public async IAsyncEnumerable BuildTestsStreamingAsync( var contextAccessor = new TestBuilderContextAccessor(new TestBuilderContext { - TestMetadata = metadata.MethodMetadata + TestMetadata = metadata.MethodMetadata, + Events = new TestContextEvents(), + ObjectBag = new Dictionary() }); // Check for circular dependency @@ -1231,6 +1241,17 @@ public async IAsyncEnumerable BuildTestsStreamingAsync( ResolvedMethodGenericArguments = resolvedMethodGenericArgs }; + // Update context BEFORE building the test (for subsequent iterations) + if (repeatIndex > 0) + { + contextAccessor.Current = new TestBuilderContext + { + TestMetadata = metadata.MethodMetadata, + Events = new TestContextEvents(), + ObjectBag = new Dictionary() + }; + } + var test = await BuildTestAsync(metadata, testData, contextAccessor.Current); if (!string.IsNullOrEmpty(basicSkipReason)) @@ -1238,11 +1259,6 @@ public async IAsyncEnumerable BuildTestsStreamingAsync( test.Context.SkipReason = basicSkipReason; } - contextAccessor.Current = new TestBuilderContext - { - TestMetadata = metadata.MethodMetadata - }; - return test; } catch (Exception ex) diff --git a/TUnit.Engine/Building/TestBuilderPipeline.cs b/TUnit.Engine/Building/TestBuilderPipeline.cs index 17e96bf38d..08eaa3e5b4 100644 --- a/TUnit.Engine/Building/TestBuilderPipeline.cs +++ b/TUnit.Engine/Building/TestBuilderPipeline.cs @@ -146,7 +146,9 @@ private async Task GenerateDynamicTests(TestMetadata m metadata.TestClassType, new TestBuilderContext { - TestMetadata = metadata.MethodMetadata + TestMetadata = metadata.MethodMetadata, + Events = new TestContextEvents(), + ObjectBag = new Dictionary() }, CancellationToken.None); @@ -262,7 +264,12 @@ private async IAsyncEnumerable BuildTestsFromSingleMetad var context = _contextProvider.CreateTestContext( resolvedMetadata.TestName, resolvedMetadata.TestClassType, - new TestBuilderContext { TestMetadata = resolvedMetadata.MethodMetadata }, + new TestBuilderContext + { + TestMetadata = resolvedMetadata.MethodMetadata, + Events = new TestContextEvents(), + ObjectBag = new Dictionary() + }, CancellationToken.None); // Set the TestDetails on the context @@ -337,7 +344,9 @@ private AbstractExecutableTest CreateFailedTestForDataGenerationError(TestMetada metadata.TestClassType, new TestBuilderContext { - TestMetadata = metadata.MethodMetadata + TestMetadata = metadata.MethodMetadata, + Events = new TestContextEvents(), + ObjectBag = new Dictionary() }, CancellationToken.None); @@ -392,7 +401,9 @@ private AbstractExecutableTest CreateFailedTestForGenericResolutionError(TestMet metadata.TestClassType, new TestBuilderContext { - TestMetadata = metadata.MethodMetadata + TestMetadata = metadata.MethodMetadata, + Events = new TestContextEvents(), + ObjectBag = new Dictionary() }, CancellationToken.None); diff --git a/TUnit.Engine/Discovery/ReflectionAttributeExtractor.cs b/TUnit.Engine/Discovery/ReflectionAttributeExtractor.cs index 8f8badc4dc..4c6e29d16b 100644 --- a/TUnit.Engine/Discovery/ReflectionAttributeExtractor.cs +++ b/TUnit.Engine/Discovery/ReflectionAttributeExtractor.cs @@ -120,24 +120,6 @@ public static bool IsTestSkipped(Type testClass, MethodInfo testMethod, out stri return skipAttr != null; } - public static int? ExtractTimeout(Type testClass, MethodInfo testMethod) - { - var timeoutAttr = GetAttribute(testClass, testMethod); - return timeoutAttr != null ? (int)timeoutAttr.Timeout.TotalMilliseconds : null; - } - - public static int ExtractRetryCount(Type testClass, MethodInfo testMethod) - { - var retryAttr = GetAttribute(testClass, testMethod); - return retryAttr?.Times ?? 0; - } - - public static int ExtractRepeatCount(Type testClass, MethodInfo testMethod) - { - var repeatAttr = GetAttribute(testClass, testMethod); - return repeatAttr?.Times ?? 0; - } - public static bool CanRunInParallel(Type testClass, MethodInfo testMethod) { return GetAttribute(testClass, testMethod) == null; diff --git a/TUnit.Engine/Framework/TUnitServiceProvider.cs b/TUnit.Engine/Framework/TUnitServiceProvider.cs index 4450bd4b1f..6a9eb59bd5 100644 --- a/TUnit.Engine/Framework/TUnitServiceProvider.cs +++ b/TUnit.Engine/Framework/TUnitServiceProvider.cs @@ -250,7 +250,7 @@ public async ValueTask DisposeAsync() { if (service is IAsyncDisposable asyncDisposable) { - await asyncDisposable.DisposeAsync(); + await asyncDisposable.DisposeAsync().ConfigureAwait(false); } else if (service is IDisposable disposable) { diff --git a/TUnit.Engine/Framework/TUnitTestFramework.cs b/TUnit.Engine/Framework/TUnitTestFramework.cs index 9fe074c87f..c9c7633141 100644 --- a/TUnit.Engine/Framework/TUnitTestFramework.cs +++ b/TUnit.Engine/Framework/TUnitTestFramework.cs @@ -50,6 +50,7 @@ public async Task ExecuteRequestAsync(ExecuteRequestContext context) serviceProvider.Initializer.Initialize(context); GlobalContext.Current = serviceProvider.ContextProvider.GlobalContext; + GlobalContext.Current.GlobalLogger = serviceProvider.Logger; BeforeTestDiscoveryContext.Current = serviceProvider.ContextProvider.BeforeTestDiscoveryContext; TestDiscoveryContext.Current = serviceProvider.ContextProvider.TestDiscoveryContext; TestSessionContext.Current = serviceProvider.ContextProvider.TestSessionContext; @@ -89,7 +90,7 @@ public async Task CloseTestSessionAsync(CloseTestSession { if (_serviceProvidersPerSession.TryRemove(context.SessionUid.Value, out var serviceProvider)) { - await serviceProvider.DisposeAsync(); + await serviceProvider.DisposeAsync().ConfigureAwait(false); } return new CloseTestSessionResult { IsSuccess = true }; diff --git a/TUnit.Engine/Logging/AsyncConsoleWriter.cs b/TUnit.Engine/Logging/AsyncConsoleWriter.cs index 72959fdf8f..004b71171d 100644 --- a/TUnit.Engine/Logging/AsyncConsoleWriter.cs +++ b/TUnit.Engine/Logging/AsyncConsoleWriter.cs @@ -318,7 +318,7 @@ public override async ValueTask DisposeAsync() } _shutdownCts.Dispose(); - await base.DisposeAsync(); + await base.DisposeAsync().ConfigureAwait(false); } #endif } \ No newline at end of file diff --git a/TUnit.Engine/Logging/BufferedTextWriter.cs b/TUnit.Engine/Logging/BufferedTextWriter.cs index f5f04a9c3f..83c6ad97c0 100644 --- a/TUnit.Engine/Logging/BufferedTextWriter.cs +++ b/TUnit.Engine/Logging/BufferedTextWriter.cs @@ -446,7 +446,7 @@ public override async ValueTask DisposeAsync() _threadLocalBuffer?.Dispose(); _lock?.Dispose(); } - await base.DisposeAsync(); + await base.DisposeAsync().ConfigureAwait(false); } #endif } \ No newline at end of file diff --git a/TUnit.Engine/Services/EventReceiverOrchestrator.cs b/TUnit.Engine/Services/EventReceiverOrchestrator.cs index 8cbf0adb24..1cf04866c8 100644 --- a/TUnit.Engine/Services/EventReceiverOrchestrator.cs +++ b/TUnit.Engine/Services/EventReceiverOrchestrator.cs @@ -348,6 +348,22 @@ private async ValueTask InvokeLastTestInSessionEventReceiversCore( await _logger.LogErrorAsync($"Error in last test in session event receiver: {ex.Message}"); } } + + // Dispose the global static property context after all tests complete + if (TestSessionContext.GlobalStaticPropertyContext.Events.OnDispose != null) + { + try + { + foreach (var invocation in TestSessionContext.GlobalStaticPropertyContext.Events.OnDispose.InvocationList.OrderBy(x => x.Order)) + { + await invocation.InvokeAsync(TestSessionContext.GlobalStaticPropertyContext, context); + } + } + catch (Exception ex) + { + await _logger.LogErrorAsync($"Error disposing global static property context: {ex.Message}"); + } + } } [MethodImpl(MethodImplOptions.AggressiveInlining)] diff --git a/TUnit.Engine/Services/SingleTestExecutor.cs b/TUnit.Engine/Services/SingleTestExecutor.cs index 00b5ad7d09..f77bdf7b9d 100644 --- a/TUnit.Engine/Services/SingleTestExecutor.cs +++ b/TUnit.Engine/Services/SingleTestExecutor.cs @@ -126,6 +126,9 @@ await PropertyInjectionService.InjectPropertiesAsync( test.Metadata.MethodMetadata, test.Context.TestDetails.TestId).ConfigureAwait(false); + // Note: Property-injected values are already tracked within PropertyInjectionService + // No need to track them again here + await _eventReceiverOrchestrator.InitializeAllEligibleObjectsAsync(test.Context, cancellationToken).ConfigureAwait(false); PopulateTestContextDependencies(test); @@ -293,6 +296,7 @@ private async Task ExecuteTestWithHooksAsync(AbstractExecutableTest test, object } finally { + // First, dispose the test instance so it can interact with injected objects during its disposal if (instance is IAsyncDisposable asyncDisposableInstance) { await asyncDisposableInstance.DisposeAsync().ConfigureAwait(false); @@ -301,6 +305,25 @@ private async Task ExecuteTestWithHooksAsync(AbstractExecutableTest test, object { disposableInstance.Dispose(); } + + // Then trigger disposal of tracked objects (injected properties and constructor args) + // This happens AFTER test instance disposal to ensure the test class can use + // these objects in its Dispose/DisposeAsync method + if (test.Context.Events.OnDispose != null) + { + foreach (var invocation in test.Context.Events.OnDispose.InvocationList.OrderBy(x => x.Order)) + { + try + { + await invocation.InvokeAsync(test.Context, test.Context).ConfigureAwait(false); + } + catch (Exception ex) + { + // Log but don't throw - we still need to dispose other objects + await _logger.LogErrorAsync($"Error during OnDispose event: {ex.Message}").ConfigureAwait(false); + } + } + } } if (testException != null) 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 d6c7e5b60a..abb8af8980 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 @@ -407,7 +407,7 @@ namespace public DataGeneratorMetadata() { } public required object?[]? ClassInstanceArguments { get; init; } public required .MemberMetadata[] MembersToGenerate { get; init; } - public required .TestBuilderContextAccessor? TestBuilderContext { get; init; } + public required .TestBuilderContextAccessor TestBuilderContext { get; init; } public required object? TestClassInstance { get; init; } public required .MethodMetadata? TestInformation { get; init; } public required string TestSessionId { get; init; } @@ -1304,7 +1304,7 @@ namespace public void WriteError(string message) { } public void WriteLine(string message) { } } - public class TestContextEvents : <.TestContextEvents> + public class TestContextEvents { public TestContextEvents() { } public .AsyncEvent<.TestContext>? OnDispose { get; set; } @@ -1482,6 +1482,7 @@ namespace public .BeforeTestDiscoveryContext TestDiscoveryContext { get; } public required string? TestFilter { get; init; } public new static .TestSessionContext? Current { get; } + public static .TestBuilderContext GlobalStaticPropertyContext { get; } public void AddArtifact(.Artifact artifact) { } public void AddAssembly(.AssemblyHookContext assemblyHookContext) { } } @@ -1629,6 +1630,13 @@ namespace .Data public TValue? Remove(TKey key) { } public bool TryGetValue(TKey key, [.(true)] out TValue? value) { } } + public class ScopedDictionary + where TScope : notnull + { + public ScopedDictionary() { } + public object? GetOrCreate(TScope scope, type, <, object?> factory) { } + public void RemoveValue(object valueToRemove) { } + } } namespace .DataSources { @@ -1996,7 +2004,7 @@ namespace .Helpers } public static class TestClassTypeHelper { - public static ? GetTestClassType(.DataGeneratorMetadata dataGeneratorMetadata) { } + public static GetTestClassType(.DataGeneratorMetadata dataGeneratorMetadata) { } } public static class TestNameGenerator { @@ -2416,15 +2424,6 @@ namespace .Models public ? MethodInvoker { get; set; } } } -namespace .ReferenceTracking -{ - public static class DataSourceReferenceExtensions - { - public static void TrackClassArguments(this .TestContext context) { } - public static void TrackMethodArguments(this .TestContext context) { } - public static void TrackPropertyArguments(this .TestContext context) { } - } -} namespace .Services { [.("Generic type resolution requires runtime type generation")] @@ -2517,4 +2516,12 @@ namespace .Services where T : class { } public object? GetService( serviceType) { } } +} +namespace .Tracking +{ + public static class ObjectTracker + { + public static void OnDisposed(object? o, action) { } + public static void TrackOwnership(object owner, object owned) { } + } } \ No newline at end of file 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 572e7d40ef..1c409c5093 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 @@ -407,7 +407,7 @@ namespace public DataGeneratorMetadata() { } public required object?[]? ClassInstanceArguments { get; init; } public required .MemberMetadata[] MembersToGenerate { get; init; } - public required .TestBuilderContextAccessor? TestBuilderContext { get; init; } + public required .TestBuilderContextAccessor TestBuilderContext { get; init; } public required object? TestClassInstance { get; init; } public required .MethodMetadata? TestInformation { get; init; } public required string TestSessionId { get; init; } @@ -1304,7 +1304,7 @@ namespace public void WriteError(string message) { } public void WriteLine(string message) { } } - public class TestContextEvents : <.TestContextEvents> + public class TestContextEvents { public TestContextEvents() { } public .AsyncEvent<.TestContext>? OnDispose { get; set; } @@ -1482,6 +1482,7 @@ namespace public .BeforeTestDiscoveryContext TestDiscoveryContext { get; } public required string? TestFilter { get; init; } public new static .TestSessionContext? Current { get; } + public static .TestBuilderContext GlobalStaticPropertyContext { get; } public void AddArtifact(.Artifact artifact) { } public void AddAssembly(.AssemblyHookContext assemblyHookContext) { } } @@ -1629,6 +1630,13 @@ namespace .Data public TValue? Remove(TKey key) { } public bool TryGetValue(TKey key, [.(true)] out TValue? value) { } } + public class ScopedDictionary + where TScope : notnull + { + public ScopedDictionary() { } + public object? GetOrCreate(TScope scope, type, <, object?> factory) { } + public void RemoveValue(object valueToRemove) { } + } } namespace .DataSources { @@ -1996,7 +2004,7 @@ namespace .Helpers } public static class TestClassTypeHelper { - public static ? GetTestClassType(.DataGeneratorMetadata dataGeneratorMetadata) { } + public static GetTestClassType(.DataGeneratorMetadata dataGeneratorMetadata) { } } public static class TestNameGenerator { @@ -2416,15 +2424,6 @@ namespace .Models public ? MethodInvoker { get; set; } } } -namespace .ReferenceTracking -{ - public static class DataSourceReferenceExtensions - { - public static void TrackClassArguments(this .TestContext context) { } - public static void TrackMethodArguments(this .TestContext context) { } - public static void TrackPropertyArguments(this .TestContext context) { } - } -} namespace .Services { [.("Generic type resolution requires runtime type generation")] @@ -2517,4 +2516,12 @@ namespace .Services where T : class { } public object? GetService( serviceType) { } } +} +namespace .Tracking +{ + public static class ObjectTracker + { + public static void OnDisposed(object? o, action) { } + public static void TrackOwnership(object owner, object owned) { } + } } \ No newline at end of file diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt index cbbc308e5b..34da9e62fb 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt @@ -367,7 +367,7 @@ namespace public DataGeneratorMetadata() { } public required object?[]? ClassInstanceArguments { get; init; } public required .MemberMetadata[] MembersToGenerate { get; init; } - public required .TestBuilderContextAccessor? TestBuilderContext { get; init; } + public required .TestBuilderContextAccessor TestBuilderContext { get; init; } public required object? TestClassInstance { get; init; } public required .MethodMetadata? TestInformation { get; init; } public required string TestSessionId { get; init; } @@ -1227,7 +1227,7 @@ namespace public void WriteError(string message) { } public void WriteLine(string message) { } } - public class TestContextEvents : <.TestContextEvents> + public class TestContextEvents { public TestContextEvents() { } public .AsyncEvent<.TestContext>? OnDispose { get; set; } @@ -1404,6 +1404,7 @@ namespace public .BeforeTestDiscoveryContext TestDiscoveryContext { get; } public required string? TestFilter { get; init; } public new static .TestSessionContext? Current { get; } + public static .TestBuilderContext GlobalStaticPropertyContext { get; } public void AddArtifact(.Artifact artifact) { } public void AddAssembly(.AssemblyHookContext assemblyHookContext) { } } @@ -1545,6 +1546,13 @@ namespace .Data public TValue? Remove(TKey key) { } public bool TryGetValue(TKey key, [.(true)] out TValue? value) { } } + public class ScopedDictionary + where TScope : notnull + { + public ScopedDictionary() { } + public object? GetOrCreate(TScope scope, type, <, object?> factory) { } + public void RemoveValue(object valueToRemove) { } + } } namespace .DataSources { @@ -1893,7 +1901,7 @@ namespace .Helpers } public static class TestClassTypeHelper { - public static ? GetTestClassType(.DataGeneratorMetadata dataGeneratorMetadata) { } + public static GetTestClassType(.DataGeneratorMetadata dataGeneratorMetadata) { } } public static class TestNameGenerator { @@ -2304,15 +2312,6 @@ namespace .Models public ? MethodInvoker { get; set; } } } -namespace .ReferenceTracking -{ - public static class DataSourceReferenceExtensions - { - public static void TrackClassArguments(this .TestContext context) { } - public static void TrackMethodArguments(this .TestContext context) { } - public static void TrackPropertyArguments(this .TestContext context) { } - } -} namespace .Services { public class GenericTypeResolver : . @@ -2403,4 +2402,12 @@ namespace .Services where T : class { } public object? GetService( serviceType) { } } +} +namespace .Tracking +{ + public static class ObjectTracker + { + public static void OnDisposed(object? o, action) { } + public static void TrackOwnership(object owner, object owned) { } + } } \ No newline at end of file diff --git a/run-source-generation-tests-verbose.ps1 b/run-source-generation-tests-verbose.ps1 new file mode 100644 index 0000000000..0f0bf10e24 --- /dev/null +++ b/run-source-generation-tests-verbose.ps1 @@ -0,0 +1,79 @@ +#!/usr/bin/env pwsh + +[CmdletBinding()] +param( + [string]$Framework = "net9.0", + [string]$Configuration = "Release", + [string]$Filter = "/*/*/*/*[EngineTest=Pass]" +) + +$ErrorActionPreference = "Stop" + +Write-Host "Running Source Generation tests with verbose output..." -ForegroundColor Yellow + +# Change to test project directory +$testProjectDir = Join-Path $PSScriptRoot "TUnit.TestProject" +if (-not (Test-Path $testProjectDir)) { + Write-Error "Test project directory not found: $testProjectDir" + exit 1 +} + +Push-Location $testProjectDir +try { + $stopwatch = [System.Diagnostics.Stopwatch]::StartNew() + + # Capture output to analyze failures + $output = dotnet run ` + -f $Framework ` + --configuration $Configuration ` + --treenode-filter $Filter ` + --no-build 2>&1 | Out-String + + # Display the full output + Write-Host $output + + # Extract and highlight failed tests + $failedTests = $output | Select-String -Pattern "^failed .*" -AllMatches + + if ($failedTests.Matches.Count -gt 0) { + Write-Host "`n=== FAILED TESTS ===" -ForegroundColor Red + foreach ($match in $failedTests.Matches) { + Write-Host $match.Value -ForegroundColor Red + + # Try to extract the next few lines for context + $startIndex = $match.Index + $contextEnd = $output.IndexOf("`n", $startIndex + 500) + if ($contextEnd -gt $startIndex) { + $context = $output.Substring($startIndex, [Math]::Min(500, $contextEnd - $startIndex)) + Write-Host $context -ForegroundColor DarkRed + } + } + } + + $success = $LASTEXITCODE -eq 0 + $stopwatch.Stop() + + # Extract summary from output + $summaryMatch = $output | Select-String -Pattern "Test run summary:.*" -AllMatches + if ($summaryMatch.Matches.Count -gt 0) { + Write-Host "`n=== TEST SUMMARY ===" -ForegroundColor Cyan + $summaryStart = $summaryMatch.Matches[0].Index + $summaryEnd = $output.IndexOf("`n`n", $summaryStart) + if ($summaryEnd -lt 0) { $summaryEnd = $output.Length } + $summary = $output.Substring($summaryStart, $summaryEnd - $summaryStart) + Write-Host $summary + } + + if ($success) { + Write-Host "`nSource Generation tests PASSED in $($stopwatch.Elapsed)" -ForegroundColor Green + } else { + Write-Host "`nSource Generation tests completed with exit code $LASTEXITCODE in $($stopwatch.Elapsed)" -ForegroundColor Yellow + } + + exit $LASTEXITCODE +} catch { + Write-Host "Source Generation tests FAILED with error: $_" -ForegroundColor Red + exit 1 +} finally { + Pop-Location +} \ No newline at end of file