Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -419,8 +419,8 @@ TUnit.TestProject/TestSession*.txt
.mcp.json
requirements

TESTPROJECT_AOT
TESTPROJECT_SINGLEFILE
TESTPROJECT_AOT*
TESTPROJECT_SINGLEFILE*

nul

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,29 @@
}
GenerateIndividualPropertyInjectionSource(context, classData);
});

// Third pipeline: Generate InitializerPropertyRegistry metadata for IAsyncInitializer types
// that have properties returning other IAsyncInitializer types.
// This enables AOT-compatible nested initializer discovery.
var asyncInitializerTypes = context.SyntaxProvider
.CreateSyntaxProvider(
predicate: (node, _) => node is TypeDeclarationSyntax,
transform: (ctx, _) => GetAsyncInitializerWithInitializerProperties(ctx))
.Where(x => x != null)
.Select((x, _) => x!)
.Collect()
.SelectMany((types, _) => types.DistinctBy(t => t.TypeSymbol, SymbolEqualityComparer.Default))
.Combine(enabledProvider);

context.RegisterSourceOutput(asyncInitializerTypes, (ctx, data) =>
{
var (typeInfo, isEnabled) = data;
if (!isEnabled)
{
return;
}
GenerateInitializerPropertySource(ctx, typeInfo);
});
}

private static bool IsClassWithDataSourceProperties(SyntaxNode node)
Expand Down Expand Up @@ -235,7 +258,7 @@
var inheritedProperties = typeSymbol.GetMembersIncludingBase()
.OfType<IPropertySymbol>()
.Where(CanSetProperty)
.Where(p => p.ContainingType != typeSymbol)

Check warning on line 261 in TUnit.Core.SourceGenerator/Generators/PropertyInjectionSourceGenerator.cs

View workflow job for this annotation

GitHub Actions / modularpipeline (macos-latest)

Use 'SymbolEqualityComparer' when comparing symbols

Check warning on line 261 in TUnit.Core.SourceGenerator/Generators/PropertyInjectionSourceGenerator.cs

View workflow job for this annotation

GitHub Actions / modularpipeline (macos-latest)

Use 'SymbolEqualityComparer' when comparing symbols
.ToList();

foreach (var property in directProperties)
Expand Down Expand Up @@ -635,6 +658,130 @@

// Alias for consistency
private static string GetNonNullableTypeName(ITypeSymbol typeSymbol) => GetNonNullableTypeString(typeSymbol);

#region IAsyncInitializer Property Discovery (for nested initializer discovery)

/// <summary>
/// Finds types that implement IAsyncInitializer and have properties that return other IAsyncInitializer types.
/// Used for AOT-compatible nested initializer discovery during object graph traversal.
/// </summary>
private static AsyncInitializerTypeInfo? GetAsyncInitializerWithInitializerProperties(GeneratorSyntaxContext context)
{
var typeDecl = (TypeDeclarationSyntax)context.Node;
var semanticModel = context.SemanticModel;

if (semanticModel.GetDeclaredSymbol(typeDecl) is not INamedTypeSymbol typeSymbol)
{
return null;
}

// Skip non-public/internal types
if (!IsPubliclyAccessible(typeSymbol))
{
return null;
}

// Skip open generic types
if (typeSymbol.IsUnboundGenericType || typeSymbol.TypeParameters.Length > 0)
{
return null;
}

var asyncInitializerInterface = semanticModel.Compilation.GetTypeByMetadataName("TUnit.Core.Interfaces.IAsyncInitializer");
if (asyncInitializerInterface == null)
{
return null;
}

// Check if this type implements IAsyncInitializer
if (!typeSymbol.AllInterfaces.Contains(asyncInitializerInterface, SymbolEqualityComparer.Default))
{
return null;
}

// Find properties that return IAsyncInitializer types
var initializerProperties = new List<InitializerPropertyMetadata>();

var allProperties = typeSymbol.GetMembers()
.OfType<IPropertySymbol>()
.Where(p => p.GetMethod != null && !p.IsStatic && !p.IsIndexer)
.ToList();

foreach (var property in allProperties)
{
// Check if the property type implements IAsyncInitializer
if (property.Type is INamedTypeSymbol propertyType)
{
if (propertyType.AllInterfaces.Contains(asyncInitializerInterface, SymbolEqualityComparer.Default) ||
SymbolEqualityComparer.Default.Equals(propertyType, asyncInitializerInterface))
{
initializerProperties.Add(new InitializerPropertyMetadata
{
Property = property
});
}
}
}

if (initializerProperties.Count == 0)
{
return null;
}

return new AsyncInitializerTypeInfo
{
TypeSymbol = typeSymbol,
Properties = initializerProperties.ToImmutableArray()
};
}

/// <summary>
/// Generates source code that registers IAsyncInitializer property metadata with InitializerPropertyRegistry.
/// </summary>
private static void GenerateInitializerPropertySource(SourceProductionContext context, AsyncInitializerTypeInfo typeInfo)
{
var typeSymbol = typeInfo.TypeSymbol;
var safeName = GetSafeClassName(typeSymbol);
var fileName = $"{safeName}_InitializerProperties.g.cs";

var sourceBuilder = new StringBuilder();

sourceBuilder.AppendLine("using System;");
sourceBuilder.AppendLine("using TUnit.Core.Discovery;");
sourceBuilder.AppendLine();
sourceBuilder.AppendLine("namespace TUnit.Generated;");
sourceBuilder.AppendLine();

// Generate module initializer
sourceBuilder.AppendLine($"internal static class {safeName}_InitializerPropertiesInitializer");
sourceBuilder.AppendLine("{");
sourceBuilder.AppendLine(" [global::System.Runtime.CompilerServices.ModuleInitializer]");
sourceBuilder.AppendLine(" public static void Initialize()");
sourceBuilder.AppendLine(" {");
sourceBuilder.AppendLine($" InitializerPropertyRegistry.Register(typeof({typeSymbol.GloballyQualified()}), new InitializerPropertyInfo[]");
sourceBuilder.AppendLine(" {");

foreach (var propInfo in typeInfo.Properties)
{
var property = propInfo.Property;
var propertyTypeName = property.Type.GloballyQualified();

sourceBuilder.AppendLine(" new InitializerPropertyInfo");
sourceBuilder.AppendLine(" {");
sourceBuilder.AppendLine($" PropertyName = \"{property.Name}\",");
sourceBuilder.AppendLine($" PropertyType = typeof({propertyTypeName}),");
sourceBuilder.AppendLine($" GetValue = static obj => (({typeSymbol.GloballyQualified()})obj).{property.Name}");
sourceBuilder.AppendLine(" },");
}

sourceBuilder.AppendLine(" });");
sourceBuilder.AppendLine(" }");
sourceBuilder.AppendLine("}");

context.AddSource(fileName, sourceBuilder.ToString());
}

#endregion
}

internal sealed class ClassWithDataSourceProperties
Expand Down Expand Up @@ -665,3 +812,21 @@
return SymbolEqualityComparer.Default.GetHashCode(obj.ClassSymbol);
}
}

/// <summary>
/// Model for types that implement IAsyncInitializer and have properties returning IAsyncInitializer.
/// Used for generating AOT-compatible nested initializer discovery metadata.
/// </summary>
internal sealed class AsyncInitializerTypeInfo
{
public required INamedTypeSymbol TypeSymbol { get; init; }
public required ImmutableArray<InitializerPropertyMetadata> Properties { get; init; }
}

/// <summary>
/// Metadata about a property that returns an IAsyncInitializer type.
/// </summary>
internal sealed class InitializerPropertyMetadata
{
public required IPropertySymbol Property { get; init; }
}
60 changes: 60 additions & 0 deletions TUnit.Core/Discovery/InitializerPropertyRegistry.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis;
using System.Reflection;
using TUnit.Core.Interfaces;

namespace TUnit.Core.Discovery;

/// <summary>
/// Registry for IAsyncInitializer property metadata generated at compile time.
/// Used for AOT-compatible nested initializer discovery.
/// </summary>
public static class InitializerPropertyRegistry
{
private static readonly ConcurrentDictionary<Type, InitializerPropertyInfo[]> Registry = new();

/// <summary>
/// Registers property metadata for a type. Called by generated code.
/// </summary>
public static void Register(Type type, InitializerPropertyInfo[] properties)
{
Registry[type] = properties;
}

/// <summary>
/// Gets property metadata for a type, or null if not registered.
/// </summary>
public static InitializerPropertyInfo[]? GetProperties(Type type)
{
return Registry.TryGetValue(type, out var properties) ? properties : null;
}

/// <summary>
/// Checks if a type has registered property metadata.
/// </summary>
public static bool HasRegistration(Type type)
{
return Registry.ContainsKey(type);
}
}

/// <summary>
/// Metadata about a property that returns an IAsyncInitializer.
/// </summary>
public sealed class InitializerPropertyInfo
{
/// <summary>
/// The name of the property.
/// </summary>
public required string PropertyName { get; init; }

/// <summary>
/// The property type.
/// </summary>
public required Type PropertyType { get; init; }

/// <summary>
/// Delegate to get the property value from an instance.
/// </summary>
public required Func<object, object?> GetValue { get; init; }
}
94 changes: 89 additions & 5 deletions TUnit.Core/Discovery/ObjectGraphDiscoverer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -420,6 +420,12 @@ private static void TraverseReflectionProperties(
/// Unified traversal for IAsyncInitializer objects (from all properties).
/// Eliminates duplicate code between DiscoverNestedInitializerObjects and DiscoverNestedInitializerObjectsForTracking.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
[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.")]
Expand All @@ -437,6 +443,80 @@ private static void TraverseInitializerProperties(
return;
}

// Try source-generated metadata first (AOT-compatible)
var registeredProperties = InitializerPropertyRegistry.GetProperties(type);
if (registeredProperties != null)
{
TraverseRegisteredInitializerProperties(obj, type, registeredProperties, tryAdd, recurse, currentDepth, cancellationToken);
return;
}

// Fall back to reflection (non-AOT path)
TraverseInitializerPropertiesViaReflection(obj, type, tryAdd, recurse, currentDepth, cancellationToken);
}

/// <summary>
/// Traverses IAsyncInitializer properties using source-generated metadata (AOT-compatible).
/// </summary>
private static void TraverseRegisteredInitializerProperties(
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;
}

// 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, propInfo.PropertyName, 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 '{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);
}
}
}

/// <summary>
/// Traverses IAsyncInitializer properties using reflection (fallback for non-source-generated types).
/// </summary>
[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)
Expand Down Expand Up @@ -473,12 +553,16 @@ private static void TraverseInitializerProperties(
}
catch (Exception ex)
{
// Record error for diagnostics (available via GetDiscoveryErrors())
// Record error for diagnostics (still available via GetDiscoveryErrors())
DiscoveryErrors.Add(new DiscoveryError(type.Name, property.Name, ex.Message, ex));
#if DEBUG
Debug.WriteLine($"[ObjectGraphDiscoverer] Failed to access property '{property.Name}' on type '{type.Name}': {ex.Message}");
#endif
// Continue discovery despite property access failures

// 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);
Comment on lines +562 to +565
Copy link

Copilot AI Dec 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error message construction spans multiple lines but uses string concatenation which creates multiple temporary strings. Consider using string interpolation for better readability and performance, or using a single multi-line string. For example: $"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."

Suggested change
$"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);
$"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);

Copilot uses AI. Check for mistakes.
}
}
}
Expand Down
Loading
Loading