diff --git a/src/Adapter/MSTestAdapter.PlatformServices/PublicAPI/PublicAPI.Unshipped.txt b/src/Adapter/MSTestAdapter.PlatformServices/PublicAPI/PublicAPI.Unshipped.txt index 245e1d9c86..dbb4e95029 100644 --- a/src/Adapter/MSTestAdapter.PlatformServices/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/Adapter/MSTestAdapter.PlatformServices/PublicAPI/PublicAPI.Unshipped.txt @@ -1,3 +1,4 @@ #nullable enable Microsoft.VisualStudio.TestPlatform.MSTestAdapter.PlatformServices.SourceGeneration.ReflectionMetadataHook static Microsoft.VisualStudio.TestPlatform.MSTestAdapter.PlatformServices.SourceGeneration.ReflectionMetadataHook.Register(System.Reflection.Assembly! assembly, System.Type![]! types, System.Collections.Generic.IReadOnlyDictionary! testMethods) -> void +static Microsoft.VisualStudio.TestPlatform.MSTestAdapter.PlatformServices.SourceGeneration.ReflectionMetadataHook.Register(System.Reflection.Assembly! assembly, System.Type![]! types, System.Collections.Generic.IReadOnlyDictionary! testMethods, System.Collections.Generic.IReadOnlyDictionary! typeAttributes, object![]! assemblyAttributes) -> void diff --git a/src/Adapter/MSTestAdapter.PlatformServices/SourceGeneration/CompositeSourceGeneratedReflectionDataProvider.cs b/src/Adapter/MSTestAdapter.PlatformServices/SourceGeneration/CompositeSourceGeneratedReflectionDataProvider.cs index 66fdcbe2a6..1f9341b7d0 100644 --- a/src/Adapter/MSTestAdapter.PlatformServices/SourceGeneration/CompositeSourceGeneratedReflectionDataProvider.cs +++ b/src/Adapter/MSTestAdapter.PlatformServices/SourceGeneration/CompositeSourceGeneratedReflectionDataProvider.cs @@ -65,8 +65,8 @@ internal override void GetNavigationData(string className, string methodName, ou internal override object[] GetAssemblyAttributes(Assembly assembly) { CompositeState state = Volatile.Read(ref _state); - return state.ProvidersByAssembly.TryGetValue(assembly, out SourceGeneratedReflectionDataProvider? provider) - ? provider.AssemblyAttributes + return state.AssemblyAttributesByAssembly.TryGetValue(assembly, out object[]? merged) + ? merged : []; } @@ -96,6 +96,7 @@ private sealed class CompositeState providers: [], providersByAssemblyName: new Dictionary(StringComparer.OrdinalIgnoreCase), providersByAssembly: [], + assemblyAttributesByAssembly: [], mergedSnapshot: new SourceGeneratedReflectionDataProvider()); #pragma warning restore IDE0028 @@ -103,11 +104,13 @@ private CompositeState( IReadOnlyList providers, Dictionary providersByAssemblyName, Dictionary providersByAssembly, + Dictionary assemblyAttributesByAssembly, SourceGeneratedReflectionDataProvider mergedSnapshot) { Providers = providers; ProvidersByAssemblyName = providersByAssemblyName; ProvidersByAssembly = providersByAssembly; + AssemblyAttributesByAssembly = assemblyAttributesByAssembly; MergedSnapshot = mergedSnapshot; } @@ -117,6 +120,12 @@ private CompositeState( public Dictionary ProvidersByAssembly { get; } + // Cumulative per-assembly assembly-level attribute union. Unlike ProvidersByAssembly (which + // keeps the last provider per assembly for type/method lookups), this dictionary preserves + // the union of AssemblyAttributes across every Register call for the same assembly so a + // future re-registration doesn't silently drop attributes published by an earlier one. + public Dictionary AssemblyAttributesByAssembly { get; } + public SourceGeneratedReflectionDataProvider MergedSnapshot { get; } public CompositeState With(SourceGeneratedReflectionDataProvider added) @@ -131,15 +140,32 @@ public CompositeState With(SourceGeneratedReflectionDataProvider added) byName[added.AssemblyName] = added; var byAssembly = new Dictionary(ProvidersByAssembly); + var attributesByAssembly = new Dictionary(AssemblyAttributesByAssembly); if (added.Assembly is { } addedAssembly) { byAssembly[addedAssembly] = added; + + if (attributesByAssembly.TryGetValue(addedAssembly, out object[]? existing) && existing.Length > 0) + { + if (added.AssemblyAttributes.Length > 0) + { + object[] merged = new object[existing.Length + added.AssemblyAttributes.Length]; + Array.Copy(existing, 0, merged, 0, existing.Length); + Array.Copy(added.AssemblyAttributes, 0, merged, existing.Length, added.AssemblyAttributes.Length); + attributesByAssembly[addedAssembly] = merged; + } + } + else if (added.AssemblyAttributes.Length > 0) + { + attributesByAssembly[addedAssembly] = added.AssemblyAttributes; + } } return new CompositeState( providers, byName, byAssembly, + attributesByAssembly, BuildMergedSnapshot(providers)); } diff --git a/src/Adapter/MSTestAdapter.PlatformServices/SourceGeneration/ReflectionMetadataHook.cs b/src/Adapter/MSTestAdapter.PlatformServices/SourceGeneration/ReflectionMetadataHook.cs index 605a25b426..564778dc98 100644 --- a/src/Adapter/MSTestAdapter.PlatformServices/SourceGeneration/ReflectionMetadataHook.cs +++ b/src/Adapter/MSTestAdapter.PlatformServices/SourceGeneration/ReflectionMetadataHook.cs @@ -21,7 +21,7 @@ namespace Microsoft.VisualStudio.TestPlatform.MSTestAdapter.PlatformServices.Sou /// that needs to call it across the assembly boundary, and module initializers cannot use /// internal APIs from a different assembly. The signature and behaviour of this hook are /// implementation details that may evolve with the generator without a major version bump; do -/// not hand-roll a call to from your own code. +/// not hand-roll a call to from your own code. /// /// /// Discovery limitation. The MSTest source generator only enumerates types that carry @@ -62,6 +62,33 @@ public static class ReflectionMetadataHook /// [EditorBrowsable(EditorBrowsableState.Never)] public static void Register(Assembly assembly, Type[] types, IReadOnlyDictionary testMethods) + => Register(assembly, types, testMethods, EmptyTypeAttributes, []); + + /// + /// Infrastructure. Publishes source-generated metadata for + /// to the MSTest adapter, including pre-materialized type-level and assembly-level attributes + /// so the adapter serves them without runtime reflection. Safe to call from multiple module + /// initializers; later registrations are merged with earlier ones. + /// + /// The test assembly the metadata describes. + /// All types directly annotated with [TestClass] in the assembly. + /// A map from each test class to its [TestMethod] set. + /// + /// A map from each test class to its pre-inflated instances. The adapter + /// returns these from GetCustomAttributes(Type) instead of reflecting at runtime. + /// + /// Pre-inflated assembly-level attribute instances. + /// + /// Do not call this method from hand-written code; it is meant to be invoked exclusively from + /// the [ModuleInitializer] emitted by the MSTest source generator. + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public static void Register( + Assembly assembly, + Type[] types, + IReadOnlyDictionary testMethods, + IReadOnlyDictionary typeAttributes, + object[] assemblyAttributes) { if (assembly is null) { @@ -78,6 +105,16 @@ public static void Register(Assembly assembly, Type[] types, IReadOnlyDictionary throw new ArgumentNullException(nameof(testMethods)); } + if (typeAttributes is null) + { + throw new ArgumentNullException(nameof(typeAttributes)); + } + + if (assemblyAttributes is null) + { + throw new ArgumentNullException(nameof(assemblyAttributes)); + } + var typesCopy = (Type[])types.Clone(); var testMethodsCopy = new Dictionary(testMethods.Count); @@ -86,6 +123,14 @@ public static void Register(Assembly assembly, Type[] types, IReadOnlyDictionary testMethodsCopy[kvp.Key] = (MethodInfo[])kvp.Value.Clone(); } + var typeAttributesCopy = new Dictionary(typeAttributes.Count); + foreach (KeyValuePair kvp in typeAttributes) + { + typeAttributesCopy[kvp.Key] = (Attribute[])kvp.Value.Clone(); + } + + object[] assemblyAttributesCopy = (object[])assemblyAttributes.Clone(); + // TypesByName must always match Type.FullName at runtime (see comment in the source // generator emitter): compute it on the runtime side from typeof(T).FullName so the // generator emits less code and the same FullName conventions are honored for nested @@ -106,6 +151,8 @@ public static void Register(Assembly assembly, Type[] types, IReadOnlyDictionary Types = typesCopy, TypesByName = typesByName, TypeMethods = testMethodsCopy, + TypeAttributes = typeAttributesCopy, + AssemblyAttributes = assemblyAttributesCopy, }; lock (Lock) @@ -121,4 +168,6 @@ public static void Register(Assembly assembly, Type[] types, IReadOnlyDictionary } } } + + private static readonly Dictionary EmptyTypeAttributes = []; } diff --git a/src/Adapter/MSTestAdapter.PlatformServices/SourceGeneration/SourceGeneratedReflectionOperations.cs b/src/Adapter/MSTestAdapter.PlatformServices/SourceGeneration/SourceGeneratedReflectionOperations.cs index c54aebc392..ea5ae9144a 100644 --- a/src/Adapter/MSTestAdapter.PlatformServices/SourceGeneration/SourceGeneratedReflectionOperations.cs +++ b/src/Adapter/MSTestAdapter.PlatformServices/SourceGeneration/SourceGeneratedReflectionOperations.cs @@ -10,14 +10,15 @@ namespace Microsoft.VisualStudio.TestPlatform.MSTestAdapter.PlatformServices.Sou /// /// /// -/// Scope of the source-generated data. The MSTest source generator's -/// [ModuleInitializer] calls with only: -/// the assembly, the [TestClass] types, and their [TestMethod]-annotated -/// s. Everything else (type attributes, method attributes, assembly -/// attributes, constructors, properties, navigation data) is intentionally not -/// populated today; reads of those fields fall back to runtime reflection. The source-gen -/// payload is therefore best understood as "type / test-method rooting + trimmer hints" -/// rather than a full reflection replacement. +/// Scope of the source-generated data. The shipping MSTest source generator's +/// [ModuleInitializer] calls +/// with only: the assembly, the [TestClass] types, and their [TestMethod]-annotated +/// s. The AOT generator additionally publishes pre-materialized type-level +/// and assembly-level attributes via the richer +/// +/// overload. Anything still not populated (method attributes, constructors, properties, navigation +/// data) falls back to runtime reflection, so the payload remains "type / test-method rooting + +/// trimmer hints + materialized type/assembly attributes" rather than a full reflection replacement. /// /// /// Why a fallback exists at all. Each fallback in this class falls into one of three diff --git a/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/MSTestReflectionMetadataGenerator.cs b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/MSTestReflectionMetadataGenerator.cs index 73cd670254..56869bd5ac 100644 --- a/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/MSTestReflectionMetadataGenerator.cs +++ b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/MSTestReflectionMetadataGenerator.cs @@ -77,6 +77,20 @@ public void Initialize(IncrementalGeneratorInitializationContext context) string source = MetadataRegistryEmitter.EmitRegistry(assemblyName, payload.Metadata, payload.Classes); ctx.AddSource("MSTestReflectionMetadata.Registry.g.cs", SourceText.From(source, Encoding.UTF8)); }); + + // Emit the [ModuleInitializer] that registers this assembly with the adapter. Without it, + // referencing this generator would emit metadata that nothing consumes. We skip emission + // when there are no test classes — there is nothing to register. + context.RegisterImplementationSourceOutput(combined, static (ctx, payload) => + { + if (payload.Classes.IsDefaultOrEmpty) + { + return; + } + + string source = RuntimeRegistrationEmitter.Emit(payload.Metadata, payload.Classes); + ctx.AddSource("MSTestReflectionMetadata.Registration.g.cs", SourceText.From(source, Encoding.UTF8)); + }); } private static TestClassTransformResult BuildResult(GeneratorAttributeSyntaxContext context, CancellationToken cancellationToken) diff --git a/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/MetadataRegistryEmitter.cs b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/MetadataRegistryEmitter.cs index 0be259b1fc..465ba62f5f 100644 --- a/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/MetadataRegistryEmitter.cs +++ b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/MetadataRegistryEmitter.cs @@ -416,7 +416,7 @@ private static void EmitAttributesProperty(IndentedStringBuilder sb, string prop } } - private static string BuildAttributeExpression(AttributeApplicationModel attribute) + internal static string BuildAttributeExpression(AttributeApplicationModel attribute) { string ctorArgs = string.Join(", ", attribute.ConstructorArguments.AsImmutableArray().Select(BuildConstantExpression)); string ctorCall = $"new {attribute.FullyQualifiedAttributeType}({ctorArgs})"; @@ -502,7 +502,7 @@ private static string BuildArgumentsFromObjectArray(EquatableArray value ? "true" : "false"; - private static string Escape(string value) + internal static string Escape(string value) => value.Replace("\\", "\\\\").Replace("\"", "\\\""); private static void AppendHeader(IndentedStringBuilder sb) diff --git a/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/RuntimeRegistrationEmitter.cs b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/RuntimeRegistrationEmitter.cs new file mode 100644 index 0000000000..df6baef1f8 --- /dev/null +++ b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/RuntimeRegistrationEmitter.cs @@ -0,0 +1,214 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.VisualStudio.TestPlatform.MSTestAdapter.PlatformServices.SourceGeneration.Helpers; + +using MSTest.AotReflection.SourceGeneration.Model; + +namespace MSTest.AotReflection.SourceGeneration.Generators; + +/// +/// Emits a single [ModuleInitializer] that registers the test assembly with MSTest's +/// adapter via ReflectionMetadataHook.Register. This is what makes a project that +/// references only this generator discoverable and runnable. +/// +/// On top of the type / test-method rooting that the shipping generator performs, this +/// registration also publishes the materialized type-level and assembly-level attributes +/// the generator already computes, so the adapter serves them from source-generated data instead +/// of calling at runtime. +/// +/// +internal static class RuntimeRegistrationEmitter +{ + private const string GeneratedTypeName = "MSTestAotSourceGeneratedReflectionMetadata"; + private const string GeneratedNamespace = "Microsoft.VisualStudio.TestPlatform.MSTestAdapter.PlatformServices.SourceGeneration.Generated.Aot"; + + public static string Emit(AssemblyMetadataModel assemblyMetadata, IReadOnlyList testClasses) + { + var sb = new IndentedStringBuilder(); + + sb.AppendLine("// "); + sb.AppendLine("// Generated by MSTest.AotReflection.SourceGeneration. Do not edit."); + sb.AppendLine("// "); + sb.AppendLine("#nullable enable"); + sb.AppendLine(); + sb.AppendLine("using System;"); + sb.AppendLine("using System.Collections.Generic;"); + sb.AppendLine("using System.Diagnostics.CodeAnalysis;"); + sb.AppendLine("using System.Reflection;"); + sb.AppendLine("using System.Runtime.CompilerServices;"); + sb.AppendLine(); + + using (sb.Block($"namespace {GeneratedNamespace}")) + { + sb.AppendLine("/// Source-generated MSTest reflection metadata hook for this test assembly."); + using (sb.Block($"internal static class {GeneratedTypeName}")) + { + sb.AppendLine("[ModuleInitializer]"); + EmitDynamicDependencies(sb, testClasses); + + using (sb.Block("internal static void Initialize()")) + { + sb.AppendLine($"var assembly = typeof({GeneratedTypeName}).Assembly;"); + EmitTypes(sb, testClasses); + EmitTestMethods(sb, testClasses); + EmitTypeAttributes(sb, testClasses); + EmitAssemblyAttributes(sb, assemblyMetadata); + sb.AppendLine($"{Constants.ReflectionMetadataHookFullName}.Register(assembly, types, testMethods, typeAttributes, assemblyAttributes);"); + } + + sb.AppendLine(); + EmitResolveMethodHelper(sb); + } + } + + return sb.ToString(); + } + + private static void EmitDynamicDependencies(IndentedStringBuilder sb, IReadOnlyList testClasses) + { + var emittedBases = new HashSet(StringComparer.Ordinal); + foreach (TestClassModel cls in testClasses) + { + sb.AppendLine($"[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof({cls.FullyQualifiedTypeName}))]"); + } + + foreach (TestClassModel cls in testClasses) + { + foreach (string baseType in cls.BaseTypeFullyQualifiedNames) + { + if (emittedBases.Add(baseType)) + { + sb.AppendLine($"[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof({baseType}))]"); + } + } + } + } + + private static void EmitTypes(IndentedStringBuilder sb, IReadOnlyList testClasses) + { + if (testClasses.Count == 0) + { + sb.AppendLine("var types = Array.Empty();"); + return; + } + + sb.AppendLine("var types = new Type[]"); + using (sb.Block(null)) + { + foreach (TestClassModel cls in testClasses) + { + sb.AppendLine($"typeof({cls.FullyQualifiedTypeName}),"); + } + } + + sb.AppendLine(";"); + } + + private static void EmitTestMethods(IndentedStringBuilder sb, IReadOnlyList testClasses) + { + sb.AppendLine("var testMethods = new Dictionary"); + using (sb.Block(null)) + { + foreach (TestClassModel cls in testClasses) + { + var testMethods = cls.Methods.AsImmutableArray().Where(m => m.IsTestMethod).ToList(); + if (testMethods.Count == 0) + { + continue; + } + + sb.AppendLine($"[typeof({cls.FullyQualifiedTypeName})] = new MethodInfo[]"); + using (sb.Block(null)) + { + foreach (TestMethodModel method in testMethods) + { + string parameterTypes = method.Parameters.Length == 0 + ? "Array.Empty()" + : "new Type[] { " + string.Join(", ", method.Parameters.AsImmutableArray().Select(p => $"typeof({p.FullyQualifiedType})")) + " }"; + sb.AppendLine($"ResolveMethod(typeof({cls.FullyQualifiedTypeName}), \"{MetadataRegistryEmitter.Escape(method.Name)}\", {parameterTypes}),"); + } + } + + sb.AppendLine(","); + } + } + + sb.AppendLine(";"); + } + + private static void EmitTypeAttributes(IndentedStringBuilder sb, IReadOnlyList testClasses) + { + sb.AppendLine("var typeAttributes = new Dictionary"); + using (sb.Block(null)) + { + foreach (TestClassModel cls in testClasses) + { + if (cls.Attributes.Length == 0) + { + continue; + } + + string attributes = string.Join(", ", cls.Attributes.AsImmutableArray().Select(MetadataRegistryEmitter.BuildAttributeExpression)); + sb.AppendLine($"[typeof({cls.FullyQualifiedTypeName})] = new Attribute[] {{ {attributes} }},"); + } + } + + sb.AppendLine(";"); + } + + private static void EmitAssemblyAttributes(IndentedStringBuilder sb, AssemblyMetadataModel assemblyMetadata) + { + if (assemblyMetadata.Attributes.Length == 0) + { + sb.AppendLine("var assemblyAttributes = Array.Empty();"); + return; + } + + string attributes = string.Join(", ", assemblyMetadata.Attributes.AsImmutableArray().Select(MetadataRegistryEmitter.BuildAttributeExpression)); + sb.AppendLine($"var assemblyAttributes = new object[] {{ {attributes} }};"); + } + + private static void EmitResolveMethodHelper(IndentedStringBuilder sb) + { + sb.AppendLine("private static MethodInfo ResolveMethod([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods | DynamicallyAccessedMemberTypes.NonPublicMethods)] Type type, string name, Type[] parameterTypes)"); + using (sb.Block(null)) + { + sb.AppendLine("const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static;"); + using (sb.Block("foreach (MethodInfo candidate in type.GetMethods(flags))")) + { + using (sb.Block("if (candidate.Name != name)")) + { + sb.AppendLine("continue;"); + } + + sb.AppendLine(); + sb.AppendLine("ParameterInfo[] candidateParameters = candidate.GetParameters();"); + using (sb.Block("if (candidateParameters.Length != parameterTypes.Length)")) + { + sb.AppendLine("continue;"); + } + + sb.AppendLine(); + sb.AppendLine("bool match = true;"); + using (sb.Block("for (int i = 0; i < candidateParameters.Length; i++)")) + { + using (sb.Block("if (candidateParameters[i].ParameterType != parameterTypes[i])")) + { + sb.AppendLine("match = false;"); + sb.AppendLine("break;"); + } + } + + sb.AppendLine(); + using (sb.Block("if (match)")) + { + sb.AppendLine("return candidate;"); + } + } + + sb.AppendLine(); + sb.AppendLine("throw new MissingMethodException(type.FullName, name);"); + } + } +} diff --git a/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/TestClassModelBuilder.cs b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/TestClassModelBuilder.cs index bbebaeff14..c10a01103f 100644 --- a/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/TestClassModelBuilder.cs +++ b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/TestClassModelBuilder.cs @@ -37,6 +37,7 @@ public static TestClassModel Build(INamedTypeSymbol typeSymbol, List.Builder methods = ImmutableArray.CreateBuilder(); ImmutableArray.Builder properties = ImmutableArray.CreateBuilder(); ImmutableArray.Builder ctors = ImmutableArray.CreateBuilder(); + ImmutableArray.Builder baseTypes = ImmutableArray.CreateBuilder(); string leafFqn = typeSymbol.ToDisplayString(FullyQualifiedFormat); @@ -46,6 +47,15 @@ public static TestClassModel Build(INamedTypeSymbol typeSymbol, List(ctors.ToImmutable()), Methods: new EquatableArray(methods.ToImmutable()), Properties: new EquatableArray(properties.ToImmutable()), - Attributes: BuildAttributes(typeSymbol.GetAttributes())); + Attributes: BuildAttributes(typeSymbol.GetAttributes()), + BaseTypeFullyQualifiedNames: new EquatableArray(baseTypes.ToImmutable())); + } + + // The generated registration lives in the same assembly as the test class, so a type is + // reachable when it (and every enclosing type) is at least internal and not file-local. + private static bool IsTypeReachableFromGeneratedCode(INamedTypeSymbol type) + { + for (INamedTypeSymbol? current = type; current is not null; current = current.ContainingType) + { + if (current.IsFileLocal) + { + return false; + } + + switch (current.DeclaredAccessibility) + { + case Accessibility.Private: + case Accessibility.Protected: + case Accessibility.ProtectedAndInternal: + return false; + } + } + + return true; + } + + private static bool IsTestMethodAttributePresent(IMethodSymbol method) + { + foreach (AttributeData attribute in method.GetAttributes()) + { + for (INamedTypeSymbol? attributeClass = attribute.AttributeClass; + attributeClass is not null; + attributeClass = attributeClass.BaseType) + { + if (attributeClass.ToDisplayString(FullyQualifiedFormat) == "global::" + MSTestAttributeNames.TestMethod) + { + return true; + } + } + } + + return false; } // Reports AOTSG0004 (generic method) and AOTSG0005 (by-ref parameter) when applicable. @@ -214,6 +266,7 @@ private static TestMethodModel BuildMethod(IMethodSymbol method) ReturnsTask: returnsTask, ReturnsValueTask: returnsValueTask, ReturnsVoid: returnsVoid, + IsTestMethod: IsTestMethodAttributePresent(method), Parameters: BuildParameters(method), Attributes: BuildAttributes(inheritedAttributes), DataRows: BuildDataRows(inheritedAttributes)); diff --git a/src/Analyzers/MSTest.AotReflection.SourceGeneration/Model/TestClassModel.cs b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Model/TestClassModel.cs index c808dbc2c3..03dcd3442a 100644 --- a/src/Analyzers/MSTest.AotReflection.SourceGeneration/Model/TestClassModel.cs +++ b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Model/TestClassModel.cs @@ -51,6 +51,7 @@ internal sealed record TestMethodModel( bool ReturnsTask, bool ReturnsValueTask, bool ReturnsVoid, + bool IsTestMethod, EquatableArray Parameters, EquatableArray Attributes, EquatableArray DataRows); @@ -83,7 +84,8 @@ internal sealed record TestClassModel( EquatableArray Constructors, EquatableArray Methods, EquatableArray Properties, - EquatableArray Attributes); + EquatableArray Attributes, + EquatableArray BaseTypeFullyQualifiedNames); /// /// Value-equatable wrapper around so incremental generation diff --git a/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTestReflectionMetadataGeneratorTests.cs b/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTestReflectionMetadataGeneratorTests.cs index 3610fe2496..15c0b1dba3 100644 --- a/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTestReflectionMetadataGeneratorTests.cs +++ b/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTestReflectionMetadataGeneratorTests.cs @@ -79,6 +79,7 @@ namespace Microsoft.VisualStudio.TestPlatform.MSTestAdapter.PlatformServices.Sou public static class ReflectionMetadataHook { public static void Register(System.Reflection.Assembly assembly, System.Type[] types, System.Collections.Generic.IReadOnlyDictionary testMethods) { } + public static void Register(System.Reflection.Assembly assembly, System.Type[] types, System.Collections.Generic.IReadOnlyDictionary testMethods, System.Collections.Generic.IReadOnlyDictionary typeAttributes, object[] assemblyAttributes) { } } } """; @@ -704,8 +705,9 @@ public void Test1() { } GeneratorDriverRunResult result = driver.GetRunResult(); result.Diagnostics.Should().BeEmpty(); result.Results.Should().ContainSingle(); - // Two passes against the same compilation must produce identical sources. - result.Results[0].GeneratedSources.Should().HaveCount(2); + // Two passes against the same compilation must produce identical sources: + // support types + registry + module-initializer registration. + result.Results[0].GeneratedSources.Should().HaveCount(3); } [TestMethod] @@ -2125,7 +2127,7 @@ public void Test() { } } [TestMethod] - public void WiringGenerator_EmitsModuleInitializer_RegisteringAssembly_AndCompilesAgainstHook() + public void Generator_EmitsModuleInitializer_RegisteringAssemblyWithAttributes_AndCompilesAgainstHook() { const string userCode = """ using Microsoft.VisualStudio.TestTools.UnitTesting; @@ -2133,33 +2135,46 @@ public void WiringGenerator_EmitsModuleInitializer_RegisteringAssembly_AndCompil namespace Sample { [TestClass] + [TestCategory("Smoke")] public class MyTests { [TestMethod] public void Test1() { } + + public void NotATest() { } } } """; - // The AOT generator package shares MSTest.SourceGeneration's proven runtime-wiring - // generator so that referencing ONLY this package makes a test assembly discoverable and - // runnable today (via ReflectionMetadataHook.Register), in addition to emitting the - // reflection-free registry the future 0%-reflection path will consume. - var wiringGenerator = new Microsoft.VisualStudio.TestPlatform.MSTestAdapter.PlatformServices.SourceGeneration.Generators.ReflectionMetadataGenerator(); - CSharpCompilation compilation = CreateCompilation(MinimalMSTestStub, RuntimeHookStub, userCode); - GeneratorDriver driver = CSharpGeneratorDriver.Create(wiringGenerator); + // The AOT generator is self-contained: it emits a single [ModuleInitializer] that registers + // the assembly with the adapter (so referencing only this package makes tests runnable), + // and the registration also publishes pre-materialized type-level attributes so the adapter + // serves them without runtime reflection. + CSharpCompilation compilation = CreateCompilation(MinimalMSTestStub, userCode); + GeneratorDriver driver = CSharpGeneratorDriver.Create(new MSTestReflectionMetadataGenerator()); driver = driver.RunGeneratorsAndUpdateCompilation(compilation, out Compilation outputCompilation, out _); GeneratorRunResult result = driver.GetRunResult().Results[0]; - result.Diagnostics.Should().BeEmpty(); - string wiring = result.GeneratedSources - .Single(s => s.HintName.EndsWith(".MSTestReflectionMetadata.g.cs", System.StringComparison.Ordinal)) + string registration = result.GeneratedSources + .Single(s => s.HintName == "MSTestReflectionMetadata.Registration.g.cs") .SourceText.ToString(); - wiring.Should().Contain("[ModuleInitializer]"); - wiring.Should().Contain("[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(global::Sample.MyTests))]"); - wiring.Should().Contain(".ReflectionMetadataHook.Register(assembly, types, testMethods);"); + registration.Should().Contain("[ModuleInitializer]"); + registration.Should().Contain("[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(global::Sample.MyTests))]"); + registration.Should().Contain(".ReflectionMetadataHook.Register(assembly, types, testMethods, typeAttributes, assemblyAttributes);"); + + // Type-level attributes are materialized into the registration (reflection-free), and only + // [TestMethod]-annotated methods are registered for the assembly. + registration.Should().Contain("[typeof(global::Sample.MyTests)] = new Attribute[] {"); + registration.Should().Contain("new global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCategoryAttribute(\"Smoke\")"); + registration.Should().Contain("\"Test1\""); + registration.Should().NotContain("\"NotATest\""); + + // The generator run itself should produce no diagnostics. This guards against future + // generator changes that emit warnings/errors going unnoticed because the emitted code + // still happens to compile. + result.Diagnostics.Should().BeEmpty(); IEnumerable errors = outputCompilation .GetDiagnostics() @@ -2191,7 +2206,10 @@ private static Compilation RunGeneratorAndGetCompilation(params string[] sources private static CSharpCompilation CreateCompilation(params string[] sources) { - IEnumerable trees = sources.Select(s => CSharpSyntaxTree.ParseText(s)); + // Always include the adapter hook stub so the emitted [ModuleInitializer] registration + // (which calls ReflectionMetadataHook.Register) compiles in the Roslyn test compilation + // without referencing MSTestAdapter.PlatformServices. + IEnumerable trees = sources.Append(RuntimeHookStub).Select(s => CSharpSyntaxTree.ParseText(s)); MetadataReference[] references = new[] { MetadataReference.CreateFromFile(typeof(object).Assembly.Location),