Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
81d1146
refactor(formatting): escape dots in strings to prevent namespace int…
thomhurst Oct 23, 2025
f1b749d
test(aot): add tests for AOT compatibility with enum arguments to pre…
thomhurst Oct 23, 2025
36a6f49
refactor(assertions): simplify assertion calls by removing unnecessar…
thomhurst Oct 23, 2025
fe7681d
refactor(matrix): streamline enum handling and remove unnecessary dyn…
thomhurst Oct 23, 2025
fa548ef
refactor(attributes): replace RequiresDynamicCode with RequiresUnrefe…
thomhurst Oct 23, 2025
735f0aa
test(aot): enhance AOT compatibility with property injection and dyna…
thomhurst Oct 23, 2025
a1d045b
refactor(tests): remove unused methods and improve AOT compatibility …
thomhurst Oct 23, 2025
3701435
chore(dependencies): update TUnit package version to 0.75.38-PullRequ…
thomhurst Oct 23, 2025
e581a0c
refactor(attributes): skip compiler-internal attributes during attrib…
thomhurst Oct 23, 2025
abb79d3
refactor(PropertyInjection): use non-nullable type names for improved…
thomhurst Oct 23, 2025
3b3f622
refactor(CastHelper): remove unnecessary suppress message for AOT com…
thomhurst Oct 23, 2025
4062403
refactor(CastHelper): simplify casting logic by removing unnecessary …
thomhurst Oct 23, 2025
099962b
refactor(CastHelper): enhance casting logic with AOT-safe conversions…
thomhurst Oct 23, 2025
a2c182b
refactor(CastHelper): optimize casting method by adding direct type c…
thomhurst Oct 23, 2025
2e31d58
refactor(CastHelper): improve AOT handling in TryReflectionConversion…
thomhurst Oct 24, 2025
c4052e9
feat: enhance AOT converter by scanning for conversion operators in s…
thomhurst Oct 24, 2025
9376b1e
feat: improve AOT converter by scanning all types in compilation for …
thomhurst Oct 24, 2025
f24825d
feat: add support for scanning closed generic types in method paramet…
thomhurst Oct 24, 2025
05063b5
feat: add AOT and single file publishing steps to CI pipeline
thomhurst Oct 24, 2025
0cdc612
feat: update TUnit package version references to use TUnitVersion var…
thomhurst Oct 24, 2025
917ea59
feat: add additional suppression message for trimming in reflection mode
thomhurst Oct 24, 2025
4a8ca00
feat: introduce IMemberMetadata interface and update related metadata…
thomhurst Oct 24, 2025
37e1e0a
fix: refine DynamicallyAccessedMembers attributes in Cast methods for…
thomhurst Oct 24, 2025
ecabc37
fix: update DynamicallyAccessedMembers attributes in Cast methods for…
thomhurst Oct 24, 2025
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
6 changes: 6 additions & 0 deletions .github/workflows/dotnet.yml
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,12 @@ jobs:
- name: Build
run: dotnet build -c Release

- name: Publish AOT
run: dotnet publish TUnit.TestProject/TUnit.TestProject.csproj -c Release --use-current-runtime -p:Aot=true -o TESTPROJECT_AOT --framework net8.0

- name: Publish Single File
run: dotnet publish TUnit.TestProject/TUnit.TestProject.csproj -c Release --use-current-runtime -p:SingleFile=true -o TESTPROJECT_SINGLEFILE --framework net8.0

- name: Run Pipeline
uses: ./.github/actions/execute-pipeline
with:
Expand Down
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@
<PackageVersion Include="Testcontainers.PostgreSql" Version="4.8.1" />
<PackageVersion Include="Testcontainers.Redis" Version="4.8.1" />
<PackageVersion Include="trxparser" Version="0.5.0" />
<PackageVersion Include="TUnit.Assertions.FSharp" Version="0.75.38-PullRequest3485.0" />
<PackageVersion Include="Verify" Version="31.0.4" />
<PackageVersion Include="Verify.NUnit" Version="31.0.4" />
<PackageVersion Include="TUnit" Version="0.75.30" />
Expand Down
5 changes: 2 additions & 3 deletions TUnit.Analyzers/AnalyzerReleases.Unshipped.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
### New Rules

Rule ID | Category | Severity | Notes
--------|----------|----------|-------
TUnit0052 | Usage | Warning | Multiple constructors found without [TestConstructor] attribute
Rule ID | Category | Severity | Notes
--------|----------|----------|-------
Original file line number Diff line number Diff line change
Expand Up @@ -92,13 +92,13 @@ public void Initialize(IncrementalGeneratorInitializationContext context)
return null;
}

// Check for RequiresDynamicCode attribute
var requiresDynamicCodeAttr = classSymbol.GetAttributes()
.FirstOrDefault(attr => attr.AttributeClass?.Name == "RequiresDynamicCodeAttribute");
string? requiresDynamicCodeMessage = null;
if (requiresDynamicCodeAttr != null && requiresDynamicCodeAttr.ConstructorArguments.Length > 0)
// Check for RequiresUnreferencedCode attribute
var RequiresUnreferencedCodeAttr = classSymbol.GetAttributes()
.FirstOrDefault(attr => attr.AttributeClass?.Name == "RequiresUnreferencedCodeAttribute");
string? RequiresUnreferencedCodeMessage = null;
if (RequiresUnreferencedCodeAttr != null && RequiresUnreferencedCodeAttr.ConstructorArguments.Length > 0)
{
requiresDynamicCodeMessage = requiresDynamicCodeAttr.ConstructorArguments[0].Value?.ToString();
RequiresUnreferencedCodeMessage = RequiresUnreferencedCodeAttr.ConstructorArguments[0].Value?.ToString();
}

return new AssertionExtensionData(
Expand All @@ -108,7 +108,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context)
assertionBaseType,
constructors,
overloadPriority,
requiresDynamicCodeMessage
RequiresUnreferencedCodeMessage
);
}

Expand Down Expand Up @@ -153,7 +153,7 @@ private static void GenerateExtensionMethods(SourceProductionContext context, As

sourceBuilder.AppendLine($"namespace TUnit.Assertions.Extensions;");
sourceBuilder.AppendLine();

// Extension class
var extensionClassName = $"{data.ClassSymbol.Name}Extensions";
sourceBuilder.AppendLine($"/// <summary>");
Expand Down Expand Up @@ -309,11 +309,11 @@ private static void GenerateExtensionMethod(
sourceBuilder.AppendLine($" /// Extension method for {assertionType.Name}.");
sourceBuilder.AppendLine(" /// </summary>");

// Add RequiresDynamicCode attribute if present
if (!string.IsNullOrEmpty(data.RequiresDynamicCodeMessage))
// Add RequiresUnreferencedCode attribute if present
if (!string.IsNullOrEmpty(data.RequiresUnreferencedCodeMessage))
{
var escapedMessage = data.RequiresDynamicCodeMessage.Replace("\"", "\\\"");
sourceBuilder.AppendLine($" [global::System.Diagnostics.CodeAnalysis.RequiresDynamicCode(\"{escapedMessage}\")]");
var escapedMessage = data.RequiresUnreferencedCodeMessage!.Replace("\"", "\\\"");
sourceBuilder.AppendLine($" [global::System.Diagnostics.CodeAnalysis.RequiresUnreferencedCode(\"{escapedMessage}\")]");
}

// Add OverloadResolutionPriority attribute only if priority > 0
Expand Down Expand Up @@ -454,6 +454,6 @@ private record AssertionExtensionData(
INamedTypeSymbol AssertionBaseType,
ImmutableArray<IMethodSymbol> Constructors,
int OverloadResolutionPriority,
string? RequiresDynamicCodeMessage
string? RequiresUnreferencedCodeMessage
);
}
2 changes: 1 addition & 1 deletion TUnit.Assertions.Tests/DictionaryCollectionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ public async Task Dictionary_IsEquivalentTo_Works()
// IsEquivalentTo works on collections regardless of order
// Cast both to IEnumerable to use collection equivalency
await Assert.That((IEnumerable<KeyValuePair<string, int>>)dictionary1)
.IsEquivalentTo((IEnumerable<KeyValuePair<string, int>>)dictionary2);
.IsEquivalentTo(dictionary2);
}

[Test]
Expand Down
2 changes: 1 addition & 1 deletion TUnit.Assertions.Tests/Old/DictionaryAssertionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ public async Task String_ReadOnlyDictionary_Contains_Key()
{
var dictionary = new ReadDictionary();

await TUnitAssert.That((IReadOnlyDictionary<string, string>)dictionary).ContainsKey("Blah");
await TUnitAssert.That(dictionary).ContainsKey("Blah");
}

[Test]
Expand Down
2 changes: 1 addition & 1 deletion TUnit.Assertions.Tests/Old/EquivalentAssertionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ public async Task Different_Dictionaries_Are_Equivalent_With_Different_Ordered_K
// Dictionaries are equivalent regardless of key order by default
// Cast both to IEnumerable to use collection equivalency
await TUnitAssert.That((IEnumerable<KeyValuePair<string, string>>)dict1)
.IsEquivalentTo((IEnumerable<KeyValuePair<string, string>>)dict2);
.IsEquivalentTo(dict2);
}

[Test]
Expand Down
2 changes: 1 addition & 1 deletion TUnit.Assertions.Tests/TypeOfTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,7 @@ public async Task CollectionAssertion_NullableByteArray_CanUseCollectionMethods(

// Should be able to use collection assertion methods
await Assert.That(nullableBytes).HasCount(5);
await Assert.That(nullableBytes).Contains((byte)3);
await Assert.That(nullableBytes).Contains(3);
await Assert.That(nullableBytes).IsInOrder();

}
Expand Down
16 changes: 12 additions & 4 deletions TUnit.Assertions/Assertions/Enums/EnumAssertions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@ protected override Task<AssertionResult> CheckAsync(EvaluationMetadata<TEnum> me
}

// Use HasFlag method for enum flag checking
var enumValue = (Enum)(object)value;
var enumFlag = (Enum)(object)_expectedFlag;
var enumValue = (Enum)value;
var enumFlag = (Enum)_expectedFlag;

if (enumValue.HasFlag(enumFlag))
{
Expand Down Expand Up @@ -70,8 +70,8 @@ protected override Task<AssertionResult> CheckAsync(EvaluationMetadata<TEnum> me
return Task.FromResult(AssertionResult.Failed($"threw {exception.GetType().Name}: {exception.Message}"));
}

var enumValue = (Enum)(object)value;
var enumFlag = (Enum)(object)_unexpectedFlag;
var enumValue = (Enum)value;
var enumFlag = (Enum)_unexpectedFlag;

if (!enumValue.HasFlag(enumFlag))
{
Expand Down Expand Up @@ -106,7 +106,11 @@ protected override Task<AssertionResult> CheckAsync(EvaluationMetadata<TEnum> me
return Task.FromResult(AssertionResult.Failed($"threw {exception.GetType().Name}: {exception.Message}"));
}

#if NET
if (Enum.IsDefined(value))
#else
if (Enum.IsDefined(typeof(TEnum), value))
#endif
{
return Task.FromResult(AssertionResult.Passed);
}
Expand Down Expand Up @@ -139,7 +143,11 @@ protected override Task<AssertionResult> CheckAsync(EvaluationMetadata<TEnum> me
return Task.FromResult(AssertionResult.Failed($"threw {exception.GetType().Name}: {exception.Message}"));
}

#if NET
if (!Enum.IsDefined(value))
#else
if (!Enum.IsDefined(typeof(TEnum), value))
#endif
{
return Task.FromResult(AssertionResult.Passed);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ namespace TUnit.Assertions.Conditions.Helpers;
/// For complex objects, performs deep comparison of properties and fields.
/// </summary>
/// <typeparam name="T">The type of objects to compare</typeparam>
[RequiresDynamicCode("Structural equality comparison uses reflection to access object members and is not compatible with AOT")]
[RequiresUnreferencedCode("Structural equality comparison uses reflection to access object members and is not compatible with AOT")]
public sealed class StructuralEqualityComparer<T> : IEqualityComparer<T>
{
/// <summary>
Expand Down
2 changes: 1 addition & 1 deletion TUnit.Assertions/Conditions/IsEquivalentToAssertion.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ namespace TUnit.Assertions.Conditions;
/// Inherits from CollectionComparerBasedAssertion to preserve collection type awareness in And/Or chains.
/// </summary>
[AssertionExtension("IsEquivalentTo")]
[RequiresDynamicCode("Collection equivalency uses structural comparison for complex objects, which requires reflection and is not compatible with AOT")]
[RequiresUnreferencedCode("Collection equivalency uses structural comparison for complex objects, which requires reflection and is not compatible with AOT")]
public class IsEquivalentToAssertion<TCollection, TItem> : CollectionComparerBasedAssertion<TCollection, TItem>
where TCollection : IEnumerable<TItem>
{
Expand Down
2 changes: 1 addition & 1 deletion TUnit.Assertions/Conditions/NotEquivalentToAssertion.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ namespace TUnit.Assertions.Conditions;
/// Inherits from CollectionComparerBasedAssertion to preserve collection type awareness in And/Or chains.
/// </summary>
[AssertionExtension("IsNotEquivalentTo")]
[RequiresDynamicCode("Collection equivalency uses structural comparison for complex objects, which requires reflection and is not compatible with AOT")]
[RequiresUnreferencedCode("Collection equivalency uses structural comparison for complex objects, which requires reflection and is not compatible with AOT")]
public class NotEquivalentToAssertion<TCollection, TItem> : CollectionComparerBasedAssertion<TCollection, TItem>
where TCollection : IEnumerable<TItem>
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using System.Text;
using TUnit.Assertions.Core;

Expand All @@ -6,6 +7,7 @@ namespace TUnit.Assertions.Conditions;
/// <summary>
/// Asserts that two objects are NOT structurally equivalent.
/// </summary>
[RequiresUnreferencedCode("Uses reflection for structural equivalency comparison")]
public class NotStructuralEquivalencyAssertion<TValue> : Assertion<TValue>
{
private readonly object? _notExpected;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ namespace TUnit.Assertions.Conditions;
/// Asserts that two objects are structurally equivalent by comparing their properties and fields.
/// Supports partial equivalency and member exclusion.
/// </summary>
[RequiresUnreferencedCode("Uses reflection to compare object properties and fields.")]
public class StructuralEquivalencyAssertion<TValue> : Assertion<TValue>
{
private readonly object? _expected;
Expand Down Expand Up @@ -166,9 +167,7 @@ internal AssertionResult CompareObjects(object? actual, object? expected, string
}

// Compare properties and fields
#pragma warning disable IL2072 // GetType() does not preserve DynamicallyAccessedMembers - acceptable for runtime structural comparison
var expectedMembers = GetMembersToCompare(expectedType);
#pragma warning restore IL2072

foreach (var member in expectedMembers)
{
Expand Down Expand Up @@ -199,9 +198,7 @@ internal AssertionResult CompareObjects(object? actual, object? expected, string
// In partial equivalency mode, skip members that don't exist on actual
if (_usePartialEquivalency)
{
#pragma warning disable IL2072 // GetType() does not preserve DynamicallyAccessedMembers - acceptable for runtime structural comparison
var actualMember = GetMemberInfo(actualType, member.Name);
#pragma warning restore IL2072
if (actualMember == null)
{
continue;
Expand All @@ -211,9 +208,7 @@ internal AssertionResult CompareObjects(object? actual, object? expected, string
}
else
{
#pragma warning disable IL2072 // GetType() does not preserve DynamicallyAccessedMembers - acceptable for runtime structural comparison
var actualMember = GetMemberInfo(actualType, member.Name);
#pragma warning restore IL2072
if (actualMember == null)
{
return AssertionResult.Failed($"Property {memberPath} did not match{Environment.NewLine}Expected: {FormatValue(expectedValue)}{Environment.NewLine}Received: null");
Expand All @@ -231,9 +226,7 @@ internal AssertionResult CompareObjects(object? actual, object? expected, string
// In non-partial mode, check for extra properties on actual
if (!_usePartialEquivalency)
{
#pragma warning disable IL2072 // GetType() does not preserve DynamicallyAccessedMembers - acceptable for runtime structural comparison
var actualMembers = GetMembersToCompare(actualType);
#pragma warning restore IL2072
var expectedMemberNames = new HashSet<string>(expectedMembers.Select(m => m.Name));

foreach (var member in actualMembers)
Expand Down
2 changes: 2 additions & 0 deletions TUnit.Assertions/Extensions/AssertionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -548,6 +548,7 @@ public static StringLengthAssertion HasLength(
/// Performs deep comparison of properties and fields.
/// Supports .WithPartialEquivalency() and .IgnoringMember() for advanced scenarios.
/// </summary>
[RequiresUnreferencedCode("Uses reflection to compare members")]
public static StructuralEquivalencyAssertion<TValue> IsEquivalentTo<TValue>(
this IAssertionSource<TValue> source,
object? expected,
Expand All @@ -562,6 +563,7 @@ public static StructuralEquivalencyAssertion<TValue> IsEquivalentTo<TValue>(
/// Performs deep comparison of properties and fields.
/// Supports .WithPartialEquivalency() and .IgnoringMember() for advanced scenarios.
/// </summary>
[RequiresUnreferencedCode("Uses reflection to compare members")]
public static NotStructuralEquivalencyAssertion<TValue> IsNotEquivalentTo<TValue>(
this IAssertionSource<TValue> source,
object? expected,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -294,7 +294,7 @@ private static void GenerateAsyncDataSourceGeneratorWithPropertyWithAssignment(C
writer.Indent();
writer.AppendLine("Type = global::TUnit.Core.Enums.DataGeneratorType.Property,");
writer.AppendLine("TestBuilderContext = new global::TUnit.Core.TestBuilderContextAccessor(globalContext),");
writer.AppendLine("MembersToGenerate = new global::TUnit.Core.MemberMetadata[] { propertyMetadata },");
writer.AppendLine("MembersToGenerate = new global::TUnit.Core.IMemberMetadata[] { propertyMetadata },");
writer.AppendLine("TestInformation = null,");
writer.AppendLine("TestSessionId = global::TUnit.Core.TestSessionContext.Current?.Id ?? \"static-property-init\",");
writer.AppendLine("TestClassInstance = null,");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ public static void WriteAttributes(ICodeWriter sourceCodeWriter, Compilation com
// Include attributes without syntax reference (from other assemblies) as long as they have an AttributeClass
if (attributeData.ApplicationSyntaxReference is not null || attributeData.AttributeClass is not null)
{
// Skip compiler-internal and assembly-level attributes
if (ShouldSkipCompilerInternalAttribute(attributeData))
{
continue;
}

// Skip framework-specific attributes when targeting older frameworks
// We determine this by checking if we can compile the attribute
if (ShouldSkipFrameworkSpecificAttribute(compilation, attributeData))
Expand All @@ -28,8 +34,8 @@ public static void WriteAttributes(ICodeWriter sourceCodeWriter, Compilation com
}

// Skip attributes with compiler-generated type arguments
if (attributeData.ConstructorArguments.Any(arg =>
arg is { Kind: TypedConstantKind.Type, Value: ITypeSymbol typeSymbol } &&
if (attributeData.ConstructorArguments.Any(arg =>
arg is { Kind: TypedConstantKind.Type, Value: ITypeSymbol typeSymbol } &&
typeSymbol.IsCompilerGeneratedType()))
{
continue;
Expand Down Expand Up @@ -274,4 +280,30 @@ private static bool IsNullableAttribute(string fullyQualifiedName)
fullyQualifiedName.Contains("NullablePublicOnlyAttribute");
}

private static bool ShouldSkipCompilerInternalAttribute(AttributeData attributeData)
{
if (attributeData.AttributeClass == null)
{
return false;
}

var fullyQualifiedName = attributeData.AttributeClass.ToDisplayString();

// Skip compiler-internal attributes that should never be re-emitted
// System.Runtime.CompilerServices contains compiler-generated and structural metadata attributes
if (fullyQualifiedName.StartsWith("System.Runtime.CompilerServices."))
{
return true;
}

// Skip debugger attributes (compiler-generated for debugging support)
if (fullyQualifiedName.StartsWith("System.Diagnostics.Debugger"))
{
return true;
}

// Skip ParamArrayAttribute (compiler-generated for params keyword)
return fullyQualifiedName == "System.ParamArrayAttribute";
}

}
Loading
Loading