From f2d365a4e31f27f9f71e7c38837a696db007b8ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Wed, 10 Jun 2026 14:42:21 +0200 Subject: [PATCH 1/8] Add MSTest.AotReflection.SourceGeneration.UnitTests Adds a focused unit-test project for the AotReflection source generator PoC introduced in #8574. The PoC had no test coverage until now. Coverage highlights (13 tests): - Support types emission (TestClassReflectionInfo, TestMethodReflectionInfo, TestPropertyReflectionInfo, TestConstructorReflectionInfo). - Registry emission shape and namespace (MSTest.SourceGenerated). - Empty registry when no [TestClass] is present. - Skipping of static / abstract / open-generic test classes. - Constructor invoker, parameter types / names, async return shape. - Class-level attribute capture; property getter & setter delegate text. - Compile-clean snapshot (catches CS errors the generator may introduce). - Incrementality: support-types step is cached when input is unchanged. Also: - Adds MSTest.AotReflection.SourceGeneration to TestFx.slnx and MSTest.slnf (missing since #8574). - Adds [InternalsVisibleTo] for the new test project (generator class is internal sealed). Part of #1837. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- MSTest.slnf | 1 + TestFx.slnx | 2 + ...Test.AotReflection.SourceGeneration.csproj | 4 + ...flection.SourceGeneration.UnitTests.csproj | 24 + .../MSTestReflectionMetadataGeneratorTests.cs | 466 ++++++++++++++++++ .../Program.cs | 19 + 6 files changed, 516 insertions(+) create mode 100644 test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests.csproj create mode 100644 test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTestReflectionMetadataGeneratorTests.cs create mode 100644 test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/Program.cs diff --git a/MSTest.slnf b/MSTest.slnf index 3c3f4c624a..f8858c6031 100644 --- a/MSTest.slnf +++ b/MSTest.slnf @@ -11,6 +11,7 @@ "src\\Analyzers\\MSTest.Analyzers.CodeFixes\\MSTest.Analyzers.CodeFixes.csproj", "src\\Analyzers\\MSTest.Analyzers.Package\\MSTest.Analyzers.Package.csproj", "src\\Analyzers\\MSTest.Analyzers\\MSTest.Analyzers.csproj", + "src\\Analyzers\\MSTest.AotReflection.SourceGeneration\\MSTest.AotReflection.SourceGeneration.csproj", "src\\Analyzers\\MSTest.GlobalConfigsGenerator\\MSTest.GlobalConfigsGenerator.csproj", "src\\Analyzers\\MSTest.SourceGeneration\\MSTest.SourceGeneration.csproj", "src\\Package\\MSTest.Sdk\\MSTest.Sdk.csproj", diff --git a/TestFx.slnx b/TestFx.slnx index 991e9dc8db..4f9fc147da 100644 --- a/TestFx.slnx +++ b/TestFx.slnx @@ -69,6 +69,7 @@ + @@ -133,6 +134,7 @@ + diff --git a/src/Analyzers/MSTest.AotReflection.SourceGeneration/MSTest.AotReflection.SourceGeneration.csproj b/src/Analyzers/MSTest.AotReflection.SourceGeneration/MSTest.AotReflection.SourceGeneration.csproj index 1369428997..dc9bbade05 100644 --- a/src/Analyzers/MSTest.AotReflection.SourceGeneration/MSTest.AotReflection.SourceGeneration.csproj +++ b/src/Analyzers/MSTest.AotReflection.SourceGeneration/MSTest.AotReflection.SourceGeneration.csproj @@ -29,4 +29,8 @@ + + + + diff --git a/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests.csproj b/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests.csproj new file mode 100644 index 0000000000..b91def2e3a --- /dev/null +++ b/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests.csproj @@ -0,0 +1,24 @@ + + + + net8.0 + MSTest.AotReflection.SourceGeneration.UnitTests + true + true + Exe + + + + + + + + + + + + + + + diff --git a/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTestReflectionMetadataGeneratorTests.cs b/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTestReflectionMetadataGeneratorTests.cs new file mode 100644 index 0000000000..2bd16432b1 --- /dev/null +++ b/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTestReflectionMetadataGeneratorTests.cs @@ -0,0 +1,466 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using AwesomeAssertions; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; + +using MSTest.AotReflection.SourceGeneration.Generators; + +namespace MSTest.AotReflection.SourceGeneration.UnitTests; + +/// +/// Behavior tests for . +/// These pin the current PoC output so the upcoming follow-up PRs (#1837) can extend it safely. +/// +[TestClass] +public sealed class MSTestReflectionMetadataGeneratorTests +{ + /// + /// Minimal MSTest attribute stubs so the generator can locate [TestClass] / + /// [TestMethod] in test fixtures without dragging the real TestFramework + /// assemblies into the Roslyn compilation. + /// + private const string MinimalMSTestStub = """ + namespace Microsoft.VisualStudio.TestTools.UnitTesting + { + [System.AttributeUsage(System.AttributeTargets.Class)] + public class TestClassAttribute : System.Attribute { } + + [System.AttributeUsage(System.AttributeTargets.Method)] + public class TestMethodAttribute : System.Attribute + { + public TestMethodAttribute() { } + public TestMethodAttribute(string displayName) { DisplayName = displayName; } + public string? DisplayName { get; set; } + } + + [System.AttributeUsage(System.AttributeTargets.Class | System.AttributeTargets.Method, AllowMultiple = true)] + public class TestCategoryAttribute : System.Attribute + { + public TestCategoryAttribute(string category) { Category = category; } + public string Category { get; } + } + + [System.AttributeUsage(System.AttributeTargets.Property)] + public class TestContextAttribute : System.Attribute { } + } + """; + + [TestMethod] + public void Generator_EmitsSupportTypes_OnAnyCompilation() + { + const string userCode = """ + // Intentionally empty — no [TestClass] in the consumer. + """; + + GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode); + + result.Diagnostics.Should().BeEmpty(); + // Support types are emitted via RegisterPostInitializationOutput → always present. + string support = result.GeneratedSources + .Single(s => s.HintName == "MSTestReflectionMetadata.SupportTypes.g.cs") + .SourceText.ToString(); + + support.Should().Contain("namespace MSTest.SourceGenerated"); + support.Should().Contain("internal sealed class TestClassReflectionInfo"); + support.Should().Contain("internal sealed class TestMethodReflectionInfo"); + support.Should().Contain("internal sealed class TestPropertyReflectionInfo"); + support.Should().Contain("internal sealed class TestConstructorReflectionInfo"); + } + + [TestMethod] + public void Generator_EmitsRegistry_WithDiscoveredTestClass() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + [TestClass] + public class MyTests + { + [TestMethod] + public void Test1() { } + } + } + """; + + GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode); + + result.Diagnostics.Should().BeEmpty(); + string registry = GetRegistry(result); + + registry.Should().Contain("internal static class MSTestReflectionMetadata"); + registry.Should().Contain("public const string AssemblyName = \"TestSample\";"); + registry.Should().Contain("Type = typeof(global::Sample.MyTests)"); + registry.Should().Contain("Name = \"Test1\""); + registry.Should().Contain("Invoke = static (instance, args) => { ((global::Sample.MyTests)instance!).Test1(); return null; },"); + } + + [TestMethod] + public void Generator_EmitsEmptyRegistry_WhenNoTestClasses() + { + const string userCode = """ + namespace Sample + { + // No [TestClass] anywhere. + public class NotATest { public void Foo() { } } + } + """; + + GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode); + + result.Diagnostics.Should().BeEmpty(); + string registry = GetRegistry(result); + registry.Should().Contain("public static IReadOnlyList TestClasses { get; } = new TestClassReflectionInfo[]"); + // No concrete TestClassReflectionInfo instance is emitted (note the open paren). + registry.Should().NotContain("new TestClassReflectionInfo("); + } + + [TestMethod] + public void Generator_SkipsStaticTestClass() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + [TestClass] + public static class StaticTests + { + [TestMethod] + public static void Test1() { } + } + } + """; + + GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode); + + result.Diagnostics.Should().BeEmpty(); + string registry = GetRegistry(result); + // Static classes are excluded by the predicate in the generator (cannot be instantiated). + registry.Should().NotContain("StaticTests"); + } + + [TestMethod] + public void Generator_SkipsAbstractTestClass() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + [TestClass] + public abstract class AbstractTests + { + [TestMethod] + public void Test1() { } + } + + [TestClass] + public class ConcreteTests + { + [TestMethod] + public void Test2() { } + } + } + """; + + GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode); + + result.Diagnostics.Should().BeEmpty(); + string registry = GetRegistry(result); + // Abstract classes are filtered in BuildModel — they cannot be instantiated. + registry.Should().NotContain("AbstractTests"); + registry.Should().Contain("typeof(global::Sample.ConcreteTests)"); + } + + [TestMethod] + public void Generator_SkipsGenericTestClass() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + [TestClass] + public class GenericTests + { + [TestMethod] + public void Test1() { } + } + } + """; + + GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode); + + result.Diagnostics.Should().BeEmpty(); + string registry = GetRegistry(result); + // Open-generic test classes are out of scope for this PoC. + registry.Should().NotContain("GenericTests"); + } + + [TestMethod] + public void Generator_EmitsConstructorInvoker() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + [TestClass] + public class CtorTests + { + public CtorTests() { } + + [TestMethod] + public void Test1() { } + } + } + """; + + GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode); + + result.Diagnostics.Should().BeEmpty(); + string registry = GetRegistry(result); + registry.Should().Contain("Constructors = new TestConstructorReflectionInfo[]"); + registry.Should().Contain("Invoke = static args => new global::Sample.CtorTests(),"); + } + + [TestMethod] + public void Generator_EmitsParameterTypes_ForMethodWithParameters() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + [TestClass] + public class ParamTests + { + [TestMethod] + public void Test1(int x, string y) { } + } + } + """; + + GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode); + + result.Diagnostics.Should().BeEmpty(); + string registry = GetRegistry(result); + registry.Should().Contain("ParameterTypes = new Type[] { typeof(int), typeof(string) }"); + registry.Should().Contain("ParameterNames = new string[] { \"x\", \"y\" }"); + } + + [TestMethod] + public void Generator_FlagsAsyncReturnTypes() + { + const string userCode = """ + using System.Threading.Tasks; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + [TestClass] + public class AsyncTests + { + [TestMethod] + public Task Test1() => Task.CompletedTask; + + [TestMethod] + public ValueTask Test2() => default; + } + } + """; + + GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode); + + result.Diagnostics.Should().BeEmpty(); + string registry = GetRegistry(result); + registry.Should().Contain("Name = \"Test1\""); + registry.Should().Contain("ReturnsTask = true"); + registry.Should().Contain("Name = \"Test2\""); + registry.Should().Contain("ReturnsValueTask = true"); + } + + [TestMethod] + public void Generator_CapturesClassLevelAttributes() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + [TestClass] + [TestCategory("Smoke")] + public class TaggedTests + { + [TestMethod] + public void Test1() { } + } + } + """; + + GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode); + + result.Diagnostics.Should().BeEmpty(); + string registry = GetRegistry(result); + registry.Should().Contain("global::Microsoft.VisualStudio.TestTools.UnitTesting.TestClassAttribute"); + registry.Should().Contain("global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCategoryAttribute"); + registry.Should().Contain("\"Smoke\""); + } + + [TestMethod] + public void Generator_EmitsPropertyGetterAndSetter() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + public class TestContext { } + + [TestClass] + public class PropTests + { + [TestContext] + public TestContext? Context { get; set; } + + [TestMethod] + public void Test1() { } + } + } + """; + + GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode); + + result.Diagnostics.Should().BeEmpty(); + string registry = GetRegistry(result); + registry.Should().Contain("Name = \"Context\""); + registry.Should().Contain("HasPublicSetter = true"); + registry.Should().Contain("Get = static instance => instance is null ? null : (object?)((global::Sample.PropTests)instance).Context,"); + registry.Should().Contain("Set = static (instance, value) => ((global::Sample.PropTests)instance!).Context = (global::Sample.TestContext?)value!,"); + } + + [TestMethod] + public void Generator_EmittedSource_CompilesCleanly() + { + // NOTE: Scenario intentionally avoids nullable reference type annotations on + // property/parameter types: the current PoC emits `typeof(T?)` verbatim, which + // is invalid C#. That bug is tracked separately for a follow-up PR; this test + // is here to prevent the emitted source from ever introducing OTHER compile + // errors. + const string userCode = """ + using System.Threading.Tasks; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + public class TestContext { } + + [TestClass] + [TestCategory("Smoke")] + public class FullShape + { + [TestContext] + public TestContext Context { get; set; } = new(); + + public FullShape() { } + + [TestMethod("alias")] + public void Sync(int x) { } + + [TestMethod] + public Task Asynchronous() => Task.CompletedTask; + } + } + """; + + Compilation outputCompilation = RunGeneratorAndGetCompilation(MinimalMSTestStub, userCode); + + IEnumerable diagnostics = outputCompilation + .GetDiagnostics() + .Where(d => d.Severity == DiagnosticSeverity.Error); + + diagnostics.Should().BeEmpty( + "the generated source MUST compile cleanly when consumed in the same compilation as the user code"); + } + + [TestMethod] + public void Generator_IsIncremental_SupportTypesAreCached_WhenInputUnchanged() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + [TestClass] + public class IncTests + { + [TestMethod] + public void Test1() { } + } + } + """; + + CSharpCompilation compilation = CreateCompilation(MinimalMSTestStub, userCode); + GeneratorDriver driver = CSharpGeneratorDriver + .Create(new MSTestReflectionMetadataGenerator()) + .WithUpdatedParseOptions((CSharpParseOptions)compilation.SyntaxTrees.First().Options); + + // Track step output cache reasons. + driver = driver.RunGenerators(compilation); + driver = driver.RunGenerators(compilation); + + 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); + } + + private static string GetRegistry(GeneratorRunResult result) + => result.GeneratedSources + .Single(s => s.HintName == "MSTestReflectionMetadata.Registry.g.cs") + .SourceText.ToString() + .Replace("\r\n", "\n"); + + private static GeneratorRunResult RunGenerator(params string[] sources) + { + CSharpCompilation compilation = CreateCompilation(sources); + GeneratorDriver driver = CSharpGeneratorDriver.Create(new MSTestReflectionMetadataGenerator()); + driver = driver.RunGeneratorsAndUpdateCompilation(compilation, out _, out _); + return driver.GetRunResult().Results[0]; + } + + private static Compilation RunGeneratorAndGetCompilation(params string[] sources) + { + CSharpCompilation compilation = CreateCompilation(sources); + GeneratorDriver driver = CSharpGeneratorDriver.Create(new MSTestReflectionMetadataGenerator()); + driver.RunGeneratorsAndUpdateCompilation(compilation, out Compilation outputCompilation, out _); + return outputCompilation; + } + + private static CSharpCompilation CreateCompilation(params string[] sources) + { + IEnumerable trees = sources.Select(s => CSharpSyntaxTree.ParseText(s)); + MetadataReference[] references = new[] + { + MetadataReference.CreateFromFile(typeof(object).Assembly.Location), + MetadataReference.CreateFromFile(typeof(System.Runtime.CompilerServices.ModuleInitializerAttribute).Assembly.Location), + MetadataReference.CreateFromFile(typeof(System.Reflection.Assembly).Assembly.Location), + MetadataReference.CreateFromFile(typeof(System.Collections.Generic.Dictionary<,>).Assembly.Location), + MetadataReference.CreateFromFile(typeof(System.Reflection.MethodInfo).Assembly.Location), + MetadataReference.CreateFromFile(typeof(System.Linq.Enumerable).Assembly.Location), + MetadataReference.CreateFromFile(typeof(System.Threading.Tasks.Task).Assembly.Location), + }; + + return CSharpCompilation.Create( + "TestSample", + trees, + references, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + } +} diff --git a/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/Program.cs b/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/Program.cs new file mode 100644 index 0000000000..9fbdc73473 --- /dev/null +++ b/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/Program.cs @@ -0,0 +1,19 @@ +// 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.Testing.Extensions; + +ITestApplicationBuilder builder = await TestApplication.CreateBuilderAsync(args); +builder.AddMSTest(() => [Assembly.GetEntryAssembly()!]); + +#if ENABLE_CODECOVERAGE +builder.AddCodeCoverageProvider(); +#endif +builder.AddHangDumpProvider(); +builder.AddCrashDumpProvider(ignoreIfNotSupported: true); +builder.AddTrxReportProvider(); +builder.AddAppInsightsTelemetryProvider(); +builder.AddAzureDevOpsProvider(); + +using ITestApplication app = await builder.BuildAsync(); +return await app.RunAsync(); From a301b8ef0e3402b59f2230e6f6bc457ceaf92abe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Wed, 10 Jun 2026 15:05:49 +0200 Subject: [PATCH 2/8] AotReflection: strip nullable annotation from emitted typeof(...) (CS8639) The PoC's TestClassModelBuilder built its fully-qualified type names with SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier, then fed the resulting FQN into both casts (where '?' is harmless and merely cosmetic, since the emitted setter already uses 'value!') and 'typeof(...)' expressions (where '?' on a reference type is invalid C# and produces CS8639). Removing the flag fixes 'typeof(string?)' / 'typeof(MyRef?)' while preserving 'typeof(int?)' (nullable value types are rendered via UseSpecialTypes, which is unaffected). Adds a focused regression test that runs the Roslyn compiler over the generated source and asserts both the textual shape ('typeof(global::Sample.TestContext)', 'typeof(string)', 'typeof(int?)') and the absence of any compile errors. Discovered while building tests for #8574; depends on #9004 for the test infrastructure. Part of #1837. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Generators/TestClassModelBuilder.cs | 3 +- .../MSTestReflectionMetadataGeneratorTests.cs | 56 ++++++++++++++++--- 2 files changed, 50 insertions(+), 9 deletions(-) diff --git a/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/TestClassModelBuilder.cs b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/TestClassModelBuilder.cs index 9188cf64c0..48d0f3209a 100644 --- a/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/TestClassModelBuilder.cs +++ b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/TestClassModelBuilder.cs @@ -19,8 +19,7 @@ internal static class TestClassModelBuilder { private static readonly SymbolDisplayFormat FullyQualifiedFormat = SymbolDisplayFormat.FullyQualifiedFormat.WithMiscellaneousOptions( - SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier - | SymbolDisplayMiscellaneousOptions.UseSpecialTypes); + SymbolDisplayMiscellaneousOptions.UseSpecialTypes); public static TestClassModel Build(INamedTypeSymbol typeSymbol) { diff --git a/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTestReflectionMetadataGeneratorTests.cs b/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTestReflectionMetadataGeneratorTests.cs index 2bd16432b1..9363460a93 100644 --- a/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTestReflectionMetadataGeneratorTests.cs +++ b/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTestReflectionMetadataGeneratorTests.cs @@ -341,17 +341,12 @@ public void Test1() { } registry.Should().Contain("Name = \"Context\""); registry.Should().Contain("HasPublicSetter = true"); registry.Should().Contain("Get = static instance => instance is null ? null : (object?)((global::Sample.PropTests)instance).Context,"); - registry.Should().Contain("Set = static (instance, value) => ((global::Sample.PropTests)instance!).Context = (global::Sample.TestContext?)value!,"); + registry.Should().Contain("Set = static (instance, value) => ((global::Sample.PropTests)instance!).Context = (global::Sample.TestContext)value!,"); } [TestMethod] public void Generator_EmittedSource_CompilesCleanly() { - // NOTE: Scenario intentionally avoids nullable reference type annotations on - // property/parameter types: the current PoC emits `typeof(T?)` verbatim, which - // is invalid C#. That bug is tracked separately for a follow-up PR; this test - // is here to prevent the emitted source from ever introducing OTHER compile - // errors. const string userCode = """ using System.Threading.Tasks; using Microsoft.VisualStudio.TestTools.UnitTesting; @@ -365,7 +360,7 @@ public class TestContext { } public class FullShape { [TestContext] - public TestContext Context { get; set; } = new(); + public TestContext? Context { get; set; } public FullShape() { } @@ -388,6 +383,53 @@ public void Sync(int x) { } "the generated source MUST compile cleanly when consumed in the same compilation as the user code"); } + [TestMethod] + public void Generator_StripsNullableAnnotation_FromTypeofExpressions() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + public class TestContext { } + + [TestClass] + public class NullableShapes + { + [TestContext] + public TestContext? Context { get; set; } + + [TestMethod] + public void TakesNullableRef(string? value) { } + + [TestMethod] + public void TakesNullableValueType(int? n) { } + } + } + """; + + Compilation outputCompilation = RunGeneratorAndGetCompilation(MinimalMSTestStub, userCode); + string registry = outputCompilation + .SyntaxTrees + .Single(t => t.FilePath.EndsWith("MSTestReflectionMetadata.Registry.g.cs", System.StringComparison.Ordinal)) + .ToString(); + + // typeof(...) MUST NOT carry nullable reference type annotation (CS8639). + registry.Should().NotContain("typeof(global::Sample.TestContext?)"); + registry.Should().NotContain("typeof(string?)"); + // Reference types in typeof drop the annotation entirely. + registry.Should().Contain("typeof(global::Sample.TestContext)"); + registry.Should().Contain("typeof(string)"); + // Nullable value types are still distinct from their underlying type and must be preserved as Nullable. + registry.Should().Contain("typeof(int?)"); + + // The whole compilation must be free of CS errors. + IEnumerable errors = outputCompilation + .GetDiagnostics() + .Where(d => d.Severity == DiagnosticSeverity.Error); + errors.Should().BeEmpty("typeof(T?) on a reference type is invalid C# (CS8639)"); + } + [TestMethod] public void Generator_IsIncremental_SupportTypesAreCached_WhenInputUnchanged() { From 45911c8f6cd473046a5bc7cb7b0b99452ce3d693 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Wed, 10 Jun 2026 15:38:28 +0200 Subject: [PATCH 3/8] AotReflection: walk inheritance chain for methods and properties Today TestClassModelBuilder only enumerates members directly declared on the `[TestClass]` type. As soon as a fixture extends a base class, MSTest members (`[TestInitialize]`, `[TestCleanup]`, `[TestMethod]`, the `[TestContext]` property, ...) declared on the base disappear from the generated registry. This change makes the builder walk the inheritance chain (stopping at `System.Object`): * Methods and properties are folded from base types into the model. * Iteration is derived-first; an override or `new`-shadowed member with the same signature/name wins over the base declaration. The signature key includes ref-kinds so genuine overloads survive. * Attributes are collected across the `OverriddenMethod` / `OverriddenProperty` chain (deduped by attribute class FQN) so an `override` that does not re-apply `[TestMethod]` still sees the base attribute - matching the runtime `GetCustomAttributes(inherit: true)` semantics. * Accessibility is broadened to include `Protected` / `ProtectedOrInternal` / `ProtectedAndInternal` so abstract bases can expose their hooks to the emitted code (which lives in the consumer's assembly). * Constructors are NEVER inherited (only taken from the leaf type). Adds 9 new tests covering: inherited methods, multi-level inheritance, overridden virtual (attribute inheritance + de-dup), `new`-hidden methods, overload preservation, inherited properties, no inherited constructors, abstract-base fold-down, and not walking past `System.Object`. Part of #1837. Depends on #9004 (test project), #9005 (typeof nullable). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Generators/TestClassModelBuilder.cs | 169 +++++++++- .../MSTestReflectionMetadataGeneratorTests.cs | 301 ++++++++++++++++++ 2 files changed, 454 insertions(+), 16 deletions(-) diff --git a/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/TestClassModelBuilder.cs b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/TestClassModelBuilder.cs index 48d0f3209a..2881029581 100644 --- a/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/TestClassModelBuilder.cs +++ b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/TestClassModelBuilder.cs @@ -1,9 +1,11 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. +using System; using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; +using System.Text; using Microsoft.CodeAnalysis; @@ -23,26 +25,57 @@ internal static class TestClassModelBuilder public static TestClassModel Build(INamedTypeSymbol typeSymbol) { + // Methods / properties are walked across the full inheritance chain (excluding + // System.Object) so that MSTest members declared on a base class — + // [ClassInitialize], [ClassCleanup], [TestInitialize], [TestCleanup], + // [TestMethod], the [TestContext] setter, … — are visible to the consumer + // without runtime reflection. + // + // Iteration order is derived-first so that an override or `new`-shadowed member + // on the derived type wins over the base declaration with the same signature. + // Constructors are NEVER inherited and are taken only from the leaf type. + var methodsByKey = new Dictionary(StringComparer.Ordinal); + var propertiesByName = new Dictionary(StringComparer.Ordinal); ImmutableArray.Builder methods = ImmutableArray.CreateBuilder(); ImmutableArray.Builder properties = ImmutableArray.CreateBuilder(); ImmutableArray.Builder ctors = ImmutableArray.CreateBuilder(); - foreach (ISymbol member in typeSymbol.GetMembers()) + for (INamedTypeSymbol? current = typeSymbol; + current is not null && current.SpecialType != SpecialType.System_Object; + current = current.BaseType) { - switch (member) + bool isLeaf = SymbolEqualityComparer.Default.Equals(current, typeSymbol); + + foreach (ISymbol member in current.GetMembers()) { - case IMethodSymbol { MethodKind: MethodKind.Ordinary } method - when method.DeclaredAccessibility is Accessibility.Public or Accessibility.Internal: - methods.Add(BuildMethod(method)); - break; - case IPropertySymbol property - when property.DeclaredAccessibility is Accessibility.Public or Accessibility.Internal: - properties.Add(BuildProperty(property)); - break; - case IMethodSymbol { MethodKind: MethodKind.Constructor, IsStatic: false } ctor - when ctor.DeclaredAccessibility is Accessibility.Public or Accessibility.Internal: - ctors.Add(new TestConstructorModel(BuildParameters(ctor))); - break; + switch (member) + { + case IMethodSymbol { MethodKind: MethodKind.Ordinary } method + when IsAccessibleFromConsumer(method): + string key = BuildMethodSignatureKey(method); + if (!methodsByKey.ContainsKey(key)) + { + TestMethodModel model = BuildMethod(method); + methodsByKey[key] = model; + methods.Add(model); + } + + break; + case IPropertySymbol property + when IsAccessibleFromConsumer(property): + if (!propertiesByName.ContainsKey(property.Name)) + { + TestPropertyModel model = BuildProperty(property); + propertiesByName[property.Name] = model; + properties.Add(model); + } + + break; + case IMethodSymbol { MethodKind: MethodKind.Constructor, IsStatic: false } ctor + when isLeaf && ctor.DeclaredAccessibility is Accessibility.Public or Accessibility.Internal: + ctors.Add(new TestConstructorModel(BuildParameters(ctor))); + break; + } } } @@ -60,6 +93,49 @@ public static TestClassModel Build(INamedTypeSymbol typeSymbol) Attributes: BuildAttributes(typeSymbol.GetAttributes())); } + private static bool IsAccessibleFromConsumer(ISymbol symbol) + => symbol.DeclaredAccessibility is + Accessibility.Public + or Accessibility.Internal + or Accessibility.Protected + or Accessibility.ProtectedOrInternal + or Accessibility.ProtectedAndInternal; + + private static string BuildMethodSignatureKey(IMethodSymbol method) + { + var sb = new StringBuilder(); + sb.Append(method.IsStatic ? "S:" : "I:"); + sb.Append(method.Name); + sb.Append('('); + bool first = true; + foreach (IParameterSymbol p in method.Parameters) + { + if (!first) + { + sb.Append(','); + } + + first = false; + switch (p.RefKind) + { + case RefKind.Ref: + sb.Append("ref "); + break; + case RefKind.Out: + sb.Append("out "); + break; + case RefKind.In: + sb.Append("in "); + break; + } + + sb.Append(p.Type.ToDisplayString(FullyQualifiedFormat)); + } + + sb.Append(')'); + return sb.ToString(); + } + private static TestMethodModel BuildMethod(IMethodSymbol method) { ITypeSymbol returnType = method.ReturnType; @@ -81,7 +157,7 @@ private static TestMethodModel BuildMethod(IMethodSymbol method) ReturnsValueTask: returnsValueTask, ReturnsVoid: returnsVoid, Parameters: BuildParameters(method), - Attributes: BuildAttributes(method.GetAttributes())); + Attributes: BuildAttributes(CollectInheritedAttributes(method))); } private static TestPropertyModel BuildProperty(IPropertySymbol property) @@ -89,7 +165,68 @@ private static TestPropertyModel BuildProperty(IPropertySymbol property) Name: property.Name, FullyQualifiedType: property.Type.ToDisplayString(FullyQualifiedFormat), HasPublicSetter: property.SetMethod is { DeclaredAccessibility: Accessibility.Public }, - Attributes: BuildAttributes(property.GetAttributes())); + Attributes: BuildAttributes(CollectInheritedAttributes(property))); + + // Mirror the runtime behavior of MemberInfo.GetCustomAttributes(inherit: true): walk the + // overridden-method chain and union attributes, keeping the most-derived application when + // the same attribute type appears on multiple levels. + private static ImmutableArray CollectInheritedAttributes(IMethodSymbol method) + { + ImmutableArray own = method.GetAttributes(); + if (method.OverriddenMethod is null) + { + return own; + } + + var seen = new HashSet(StringComparer.Ordinal); + ImmutableArray.Builder builder = ImmutableArray.CreateBuilder(); + AppendUnique(builder, seen, own); + for (IMethodSymbol? baseMethod = method.OverriddenMethod; baseMethod is not null; baseMethod = baseMethod.OverriddenMethod) + { + AppendUnique(builder, seen, baseMethod.GetAttributes()); + } + + return builder.ToImmutable(); + } + + private static ImmutableArray CollectInheritedAttributes(IPropertySymbol property) + { + ImmutableArray own = property.GetAttributes(); + if (property.OverriddenProperty is null) + { + return own; + } + + var seen = new HashSet(StringComparer.Ordinal); + ImmutableArray.Builder builder = ImmutableArray.CreateBuilder(); + AppendUnique(builder, seen, own); + for (IPropertySymbol? baseProperty = property.OverriddenProperty; baseProperty is not null; baseProperty = baseProperty.OverriddenProperty) + { + AppendUnique(builder, seen, baseProperty.GetAttributes()); + } + + return builder.ToImmutable(); + } + + private static void AppendUnique( + ImmutableArray.Builder builder, + HashSet seen, + ImmutableArray attributes) + { + foreach (AttributeData attribute in attributes) + { + if (attribute.AttributeClass is not { } attributeClass) + { + continue; + } + + string key = attributeClass.ToDisplayString(FullyQualifiedFormat); + if (seen.Add(key)) + { + builder.Add(attribute); + } + } + } private static EquatableArray BuildParameters(IMethodSymbol method) { diff --git a/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTestReflectionMetadataGeneratorTests.cs b/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTestReflectionMetadataGeneratorTests.cs index 9363460a93..6d7daadf99 100644 --- a/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTestReflectionMetadataGeneratorTests.cs +++ b/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTestReflectionMetadataGeneratorTests.cs @@ -45,6 +45,12 @@ public class TestCategoryAttribute : System.Attribute [System.AttributeUsage(System.AttributeTargets.Property)] public class TestContextAttribute : System.Attribute { } + + [System.AttributeUsage(System.AttributeTargets.Method)] + public class TestInitializeAttribute : System.Attribute { } + + [System.AttributeUsage(System.AttributeTargets.Method)] + public class TestCleanupAttribute : System.Attribute { } } """; @@ -463,6 +469,301 @@ public void Test1() { } result.Results[0].GeneratedSources.Should().HaveCount(2); } + [TestMethod] + public void Generator_IncludesMethodsFromBaseType() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + public class BaseTests + { + [TestInitialize] + public void Setup() { } + + [TestMethod] + public void InheritedTest() { } + } + + [TestClass] + public class DerivedTests : BaseTests + { + [TestMethod] + public void DerivedTest() { } + } + } + """; + + string registry = GetRegistry(RunGenerator(MinimalMSTestStub, userCode)); + + registry.Should().Contain("Name = \"InheritedTest\""); + registry.Should().Contain("Name = \"Setup\""); + registry.Should().Contain("Name = \"DerivedTest\""); + // The TestInitialize attribute applied on the base method must propagate too. + registry.Should().Contain("global::Microsoft.VisualStudio.TestTools.UnitTesting.TestInitializeAttribute"); + } + + [TestMethod] + public void Generator_IncludesMethodsFromMultiLevelInheritance() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + public class GrandparentTests + { + [TestMethod] + public void GrandparentTest() { } + } + + public class ParentTests : GrandparentTests + { + [TestMethod] + public void ParentTest() { } + } + + [TestClass] + public class LeafTests : ParentTests + { + [TestMethod] + public void LeafTest() { } + } + } + """; + + string registry = GetRegistry(RunGenerator(MinimalMSTestStub, userCode)); + + registry.Should().Contain("Name = \"GrandparentTest\""); + registry.Should().Contain("Name = \"ParentTest\""); + registry.Should().Contain("Name = \"LeafTest\""); + } + + [TestMethod] + public void Generator_OverriddenVirtualMethod_KeepsOnlyDerivedImplementation() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + public class BaseTests + { + [TestMethod] + public virtual void Run() { } + } + + [TestClass] + public class DerivedTests : BaseTests + { + public override void Run() { } + } + } + """; + + string registry = GetRegistry(RunGenerator(MinimalMSTestStub, userCode)); + + // Only one entry for Run should be emitted, and the invoker must dispatch on the derived type. + int runEntries = registry.Split(["Name = \"Run\""], System.StringSplitOptions.None).Length - 1; + runEntries.Should().Be(1, "the derived override must replace the base entry (not duplicate it)"); + registry.Should().Contain("((global::Sample.DerivedTests)instance!).Run();"); + registry.Should().NotContain("((global::Sample.BaseTests)instance!).Run();"); + + // The override does NOT re-apply [TestMethod] but the attribute must still be visible + // via the override chain — matching the runtime semantics of GetCustomAttributes(inherit: true). + registry.Should().Contain("global::Microsoft.VisualStudio.TestTools.UnitTesting.TestMethodAttribute"); + } + + [TestMethod] + public void Generator_NewKeywordHiddenMethod_DedupsBySignature() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + public class BaseTests + { + [TestMethod] + public void Hidden() { } + } + + [TestClass] + public class DerivedTests : BaseTests + { + [TestMethod] + public new void Hidden() { } + } + } + """; + + string registry = GetRegistry(RunGenerator(MinimalMSTestStub, userCode)); + + int hiddenEntries = registry.Split(["Name = \"Hidden\""], System.StringSplitOptions.None).Length - 1; + hiddenEntries.Should().Be(1, "members with the same name and signature must be de-duplicated; derived wins"); + registry.Should().Contain("((global::Sample.DerivedTests)instance!).Hidden();"); + } + + [TestMethod] + public void Generator_OverloadsWithDifferentSignatures_AreAllPreserved() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + public class BaseTests + { + [TestMethod] + public void Op(int x) { } + } + + [TestClass] + public class DerivedTests : BaseTests + { + [TestMethod] + public void Op(string x) { } + } + } + """; + + string registry = GetRegistry(RunGenerator(MinimalMSTestStub, userCode)); + + // Both overloads survive — they have different signatures. + int opEntries = registry.Split(["Name = \"Op\""], System.StringSplitOptions.None).Length - 1; + opEntries.Should().Be(2); + registry.Should().Contain("typeof(int)"); + registry.Should().Contain("typeof(string)"); + } + + [TestMethod] + public void Generator_IncludesPropertiesFromBaseType() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + public class TestContext { } + + public class BaseTests + { + [TestContext] + public virtual TestContext Context { get; set; } = new(); + } + + [TestClass] + public class DerivedTests : BaseTests + { + [TestMethod] + public void Test() { } + } + } + """; + + string registry = GetRegistry(RunGenerator(MinimalMSTestStub, userCode)); + + registry.Should().Contain("Name = \"Context\""); + registry.Should().Contain("global::Microsoft.VisualStudio.TestTools.UnitTesting.TestContextAttribute"); + } + + [TestMethod] + public void Generator_DoesNotInheritConstructors() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + public class BaseTests + { + public BaseTests(int x) { } + } + + [TestClass] + public class DerivedTests : BaseTests + { + public DerivedTests() : base(1) { } + + [TestMethod] + public void Test() { } + } + } + """; + + string registry = GetRegistry(RunGenerator(MinimalMSTestStub, userCode)); + + // Only the derived ctor (parameterless) should be emitted — base ctor is never inherited. + registry.Should().Contain("Invoke = static args => new global::Sample.DerivedTests(),"); + registry.Should().NotContain("Invoke = static args => new global::Sample.BaseTests("); + // No int parameter from the base constructor leaks into the constructor list. + registry.Should().NotContain("ParameterTypes = new Type[] { typeof(int) },"); + } + + [TestMethod] + public void Generator_AbstractBaseWithConcreteDerived_FoldsBaseMembers() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + public class TestContext { } + + public abstract class AbstractBase + { + [TestInitialize] + public void Setup() { } + + [TestContext] + public TestContext Ctx { get; set; } = new(); + } + + [TestClass] + public class Concrete : AbstractBase + { + [TestMethod] + public void Test() { } + } + } + """; + + string registry = GetRegistry(RunGenerator(MinimalMSTestStub, userCode)); + + registry.Should().Contain("Name = \"Setup\""); + registry.Should().Contain("Name = \"Ctx\""); + registry.Should().Contain("Name = \"Test\""); + // The base class was abstract but the concrete derived type is the one emitted. + registry.Should().Contain("Type = typeof(global::Sample.Concrete)"); + } + + [TestMethod] + public void Generator_DoesNotWalkPastSystemObject() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + [TestClass] + public class SimpleTests + { + [TestMethod] + public void Test() { } + } + } + """; + + string registry = GetRegistry(RunGenerator(MinimalMSTestStub, userCode)); + + // Members of System.Object (ToString, Equals, GetHashCode, GetType) must NOT be emitted. + registry.Should().NotContain("Name = \"ToString\""); + registry.Should().NotContain("Name = \"Equals\""); + registry.Should().NotContain("Name = \"GetHashCode\""); + registry.Should().NotContain("Name = \"GetType\""); + } + private static string GetRegistry(GeneratorRunResult result) => result.GeneratedSources .Single(s => s.HintName == "MSTestReflectionMetadata.Registry.g.cs") From e4d126ea8daf0c5078753dfc9b452abfc550c0f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Wed, 10 Jun 2026 15:55:19 +0200 Subject: [PATCH 4/8] AotReflection: capture assembly-level attributes Adds an AssemblyAttributes property to the emitted MSTestReflectionMetadata registry containing all attributes declared with [assembly: ...] in the same compilation. The attribute payload is built via the existing AttributeApplicationModel pipeline (reused from class/method attribute emission), so adapters can iterate without calling Assembly.GetCustomAttributes at runtime. Part of #1837. Stacked on #9004, #9005, #9006. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../MSTestReflectionMetadataGenerator.cs | 19 ++- .../Generators/MetadataRegistryEmitter.cs | 37 +++++- .../Model/TestClassModel.cs | 8 ++ .../MSTestReflectionMetadataGeneratorTests.cs | 119 ++++++++++++++++++ 4 files changed, 179 insertions(+), 4 deletions(-) diff --git a/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/MSTestReflectionMetadataGenerator.cs b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/MSTestReflectionMetadataGenerator.cs index b94f9bc9ca..c0baa4025b 100644 --- a/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/MSTestReflectionMetadataGenerator.cs +++ b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/MSTestReflectionMetadataGenerator.cs @@ -45,14 +45,27 @@ node is TypeDeclarationSyntax type .Where(static model => model is not null) .Select(static (model, _) => model!); - IncrementalValueProvider<(string? AssemblyName, ImmutableArray Classes)> combined = + // Pull assembly-level attributes from the compilation (one value per run) and + // wrap them in an equatable model so this branch of the pipeline can stay cached + // when only test-class code changes. + IncrementalValueProvider assemblyMetadata = + context.CompilationProvider.Select(static (c, ct) => + { + ct.ThrowIfCancellationRequested(); + return new AssemblyMetadataModel( + TestClassModelBuilder.BuildAttributes(c.Assembly.GetAttributes())); + }); + + IncrementalValueProvider<(string? AssemblyName, AssemblyMetadataModel Metadata, ImmutableArray Classes)> combined = context.CompilationProvider.Select(static (c, _) => c.AssemblyName) - .Combine(testClasses.Collect()); + .Combine(assemblyMetadata) + .Combine(testClasses.Collect()) + .Select(static (tuple, _) => (tuple.Left.Left, tuple.Left.Right, tuple.Right)); context.RegisterImplementationSourceOutput(combined, static (ctx, payload) => { string assemblyName = payload.AssemblyName ?? "Unknown"; - string source = MetadataRegistryEmitter.EmitRegistry(assemblyName, payload.Classes); + string source = MetadataRegistryEmitter.EmitRegistry(assemblyName, payload.Metadata, payload.Classes); ctx.AddSource("MSTestReflectionMetadata.Registry.g.cs", SourceText.From(source, Encoding.UTF8)); }); } diff --git a/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/MetadataRegistryEmitter.cs b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/MetadataRegistryEmitter.cs index 7582344e7d..e4779cf4f6 100644 --- a/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/MetadataRegistryEmitter.cs +++ b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/MetadataRegistryEmitter.cs @@ -83,7 +83,7 @@ public static string EmitSupportTypes() return sb.ToString(); } - public static string EmitRegistry(string assemblyName, IReadOnlyList testClasses) + public static string EmitRegistry(string assemblyName, AssemblyMetadataModel assemblyMetadata, IReadOnlyList testClasses) { var sb = new IndentedStringBuilder(); AppendHeader(sb); @@ -99,6 +99,12 @@ public static string EmitRegistry(string assemblyName, IReadOnlyList TestClasses { get; } = new TestClassReflectionInfo[]"); using (sb.Block(null)) { @@ -119,6 +125,35 @@ public static string EmitRegistry(string assemblyName, IReadOnlyList attributes) + { + if (attributes.Length == 0) + { + sb.AppendLine("public static IReadOnlyList AssemblyAttributes { get; } = Array.Empty();"); + return; + } + + sb.AppendLine("public static IReadOnlyList AssemblyAttributes { get; } = new Attribute[]"); + using (sb.Block(null)) + { + for (int i = 0; i < attributes.Length; i++) + { + AttributeApplicationModel attr = attributes[i]; + sb.Append(BuildAttributeExpression(attr)); + if (i < attributes.Length - 1) + { + sb.AppendLine(","); + } + else + { + sb.AppendLine(); + } + } + } + + sb.AppendLine(";"); + } + private static void EmitTestClass(IndentedStringBuilder sb, TestClassModel model) { string fqn = model.FullyQualifiedTypeName; diff --git a/src/Analyzers/MSTest.AotReflection.SourceGeneration/Model/TestClassModel.cs b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Model/TestClassModel.cs index cc7586fdef..db2818dfce 100644 --- a/src/Analyzers/MSTest.AotReflection.SourceGeneration/Model/TestClassModel.cs +++ b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Model/TestClassModel.cs @@ -60,6 +60,14 @@ internal sealed record TestPropertyModel( internal sealed record TestConstructorModel( EquatableArray Parameters); +/// +/// Assembly-scoped metadata captured at compile time so the consumer never has to call +/// for +/// attributes declared with [assembly: ...] in the same compilation. +/// +internal sealed record AssemblyMetadataModel( + EquatableArray Attributes); + internal sealed record TestClassModel( string FullyQualifiedTypeName, string ContainingNamespace, diff --git a/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTestReflectionMetadataGeneratorTests.cs b/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTestReflectionMetadataGeneratorTests.cs index 6d7daadf99..04a13d6624 100644 --- a/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTestReflectionMetadataGeneratorTests.cs +++ b/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTestReflectionMetadataGeneratorTests.cs @@ -51,6 +51,13 @@ public class TestInitializeAttribute : System.Attribute { } [System.AttributeUsage(System.AttributeTargets.Method)] public class TestCleanupAttribute : System.Attribute { } + + [System.AttributeUsage(System.AttributeTargets.Assembly, AllowMultiple = true)] + public class ParallelizeAttribute : System.Attribute + { + public int Workers { get; set; } + public string? Scope { get; set; } + } } """; @@ -764,6 +771,118 @@ public void Test() { } registry.Should().NotContain("Name = \"GetType\""); } + [TestMethod] + public void Generator_CapturesAssemblyLevelAttribute() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + [assembly: Parallelize(Workers = 4, Scope = "Method")] + + namespace Sample + { + [TestClass] + public class Tests + { + [TestMethod] + public void Test() { } + } + } + """; + + GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode); + + result.Diagnostics.Should().BeEmpty(); + string registry = GetRegistry(result); + registry.Should().Contain("public static IReadOnlyList AssemblyAttributes { get; } = new Attribute[]"); + registry.Should().Contain("new global::Microsoft.VisualStudio.TestTools.UnitTesting.ParallelizeAttribute()"); + registry.Should().Contain("Workers = 4"); + registry.Should().Contain("Scope = \"Method\""); + } + + [TestMethod] + public void Generator_AssemblyAttributes_IsEmptyArray_WhenNoneApplied() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + [TestClass] + public class Tests + { + [TestMethod] + public void Test() { } + } + } + """; + + GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode); + + result.Diagnostics.Should().BeEmpty(); + string registry = GetRegistry(result); + registry.Should().Contain("public static IReadOnlyList AssemblyAttributes { get; } = Array.Empty();"); + registry.Should().NotContain("public static IReadOnlyList AssemblyAttributes { get; } = new Attribute[]"); + } + + [TestMethod] + public void Generator_CapturesMultipleAssemblyAttributes_InDeclarationOrder() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + [assembly: Parallelize(Workers = 1)] + [assembly: Parallelize(Workers = 2)] + [assembly: Parallelize(Workers = 3)] + + namespace Sample + { + [TestClass] + public class Tests + { + [TestMethod] + public void Test() { } + } + } + """; + + GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode); + + result.Diagnostics.Should().BeEmpty(); + string registry = GetRegistry(result); + + int idx1 = registry.IndexOf("Workers = 1", StringComparison.Ordinal); + int idx2 = registry.IndexOf("Workers = 2", StringComparison.Ordinal); + int idx3 = registry.IndexOf("Workers = 3", StringComparison.Ordinal); + + idx1.Should().BeGreaterThan(-1); + idx2.Should().BeGreaterThan(idx1); + idx3.Should().BeGreaterThan(idx2); + } + + [TestMethod] + public void Generator_AssemblyAttributes_AreEmittedEvenWithNoTestClasses() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + [assembly: Parallelize(Workers = 8)] + + namespace Sample + { + public class NotATest { } + } + """; + + GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode); + + result.Diagnostics.Should().BeEmpty(); + string registry = GetRegistry(result); + registry.Should().Contain("new global::Microsoft.VisualStudio.TestTools.UnitTesting.ParallelizeAttribute()"); + registry.Should().Contain("Workers = 8"); + registry.Should().NotContain("new TestClassReflectionInfo("); + } + private static string GetRegistry(GeneratorRunResult result) => result.GeneratedSources .Single(s => s.HintName == "MSTestReflectionMetadata.Registry.g.cs") From 517e185c957c9ec0d9f630befd6748638451b81a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Wed, 10 Jun 2026 16:40:44 +0200 Subject: [PATCH 5/8] A4: Materialize [DataRow] arguments into TestMethodReflectionInfo Part of #1837. Adds compile-time materialization of `[DataRow]` attribute applications on `[TestMethod]` members. The generator now emits a `DataRows` property on each `TestMethodReflectionInfo` containing a flat `IReadOnlyList` mirroring the runtime shape of `DataRowAttribute.Data`, so a consumer can iterate parameterised cases without re-reading the attributes via reflection. Highlights: - New `DataRowModel(EquatableArray Arguments)` capturing one row of arguments per attribute application. - `BuildDataRows` detects `Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute` applications and flattens the variadic `params object?[] moreData` tail back into the row so the emitted array matches `DataRowAttribute.Data` rather than preserving a nested array. - Inheritance-aware: reuses the inherited attribute walk introduced in #9006, so `[DataRow]` applied on a base method (when the override is virtual) is still picked up. - Emitter always emits `DataRows` (empty array for non-data-driven tests) for shape parity with the other `TestMethodReflectionInfo` properties. Deferred to a follow-up: `[DynamicData]` materialization. Resolving the data source method/property/field at compile time is materially more complex (handles `Method`/`Property`/`Field`/`AutoDetect` source kinds plus `object[]` / `IEnumerable` return shapes) and warrants its own PR. Tests: - `Generator_EmitsEmptyDataRows_WhenMethodHasNoDataRow` - `Generator_CapturesSingleDataRow_WithScalarArgs` - `Generator_CapturesMultipleDataRows_InDeclarationOrder` - `Generator_FlattensParamsArrayInDataRow` - `Generator_HandlesNullValueInDataRow` Total: 32/32 passing. Depends on #9004, #9005, #9006, #9007. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Generators/MetadataRegistryEmitter.cs | 42 +++++ .../Generators/TestClassModelBuilder.cs | 61 ++++++- .../Model/TestClassModel.cs | 9 +- .../MSTestReflectionMetadataGeneratorTests.cs | 149 ++++++++++++++++++ 4 files changed, 259 insertions(+), 2 deletions(-) diff --git a/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/MetadataRegistryEmitter.cs b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/MetadataRegistryEmitter.cs index e4779cf4f6..50ad1b1703 100644 --- a/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/MetadataRegistryEmitter.cs +++ b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/MetadataRegistryEmitter.cs @@ -57,6 +57,8 @@ public static string EmitSupportTypes() sb.AppendLine("public Type[] ParameterTypes { get; init; } = Array.Empty();"); sb.AppendLine("public string[] ParameterNames { get; init; } = Array.Empty();"); sb.AppendLine("public Attribute[] Attributes { get; init; } = Array.Empty();"); + sb.AppendLine("/// Materialized argument tuples from [DataRow] attributes (empty for non-data-driven tests). Each object?[] corresponds to one [DataRow] application."); + sb.AppendLine("public IReadOnlyList DataRows { get; init; } = Array.Empty();"); sb.AppendLine("/// Direct invoker — replaces ."); sb.AppendLine("public Func Invoke { get; init; } = static (_, _) => null;"); } @@ -225,6 +227,8 @@ private static void EmitMethods(IndentedStringBuilder sb, string fqn, TestClassM sb.AppendLine(","); EmitAttributesProperty(sb, "Attributes", method.Attributes); sb.AppendLine(","); + EmitDataRows(sb, method.DataRows); + sb.AppendLine(","); EmitMethodInvoker(sb, fqn, method); } @@ -280,6 +284,44 @@ private static void EmitMethodInvoker(IndentedStringBuilder sb, string classFqn, sb.AppendLine($"Invoke = static (instance, args) => {body},"); } + private static void EmitDataRows(IndentedStringBuilder sb, EquatableArray dataRows) + { + if (dataRows.Length == 0) + { + sb.Append("DataRows = Array.Empty()"); + sb.AppendLine(); + return; + } + + sb.AppendLine("DataRows = new object?[][]"); + using (sb.Block(null)) + { + for (int i = 0; i < dataRows.Length; i++) + { + EquatableArray args = dataRows[i].Arguments; + if (args.Length == 0) + { + sb.Append("Array.Empty()"); + } + else + { + string literals = string.Join(", ", args.AsImmutableArray().Select(BuildConstantExpression)); + sb.Append($"new object?[] {{ {literals} }}"); + } + + if (i < dataRows.Length - 1) + { + sb.AppendLine(","); + } + else + { + sb.AppendLine(); + } + } + } + } + + private static void EmitParameterTypes(IndentedStringBuilder sb, EquatableArray parameters) { if (parameters.Length == 0) diff --git a/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/TestClassModelBuilder.cs b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/TestClassModelBuilder.cs index 2881029581..c5029b4100 100644 --- a/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/TestClassModelBuilder.cs +++ b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/TestClassModelBuilder.cs @@ -9,6 +9,7 @@ using Microsoft.CodeAnalysis; +using MSTest.AotReflection.SourceGeneration.Helpers; using MSTest.AotReflection.SourceGeneration.Model; namespace MSTest.AotReflection.SourceGeneration.Generators; @@ -149,6 +150,8 @@ private static TestMethodModel BuildMethod(IMethodSymbol method) || returnTypeFqn.StartsWith("global::System.Threading.Tasks.ValueTask<", System.StringComparison.Ordinal); bool returnsVoid = returnType.SpecialType == SpecialType.System_Void; + ImmutableArray inheritedAttributes = CollectInheritedAttributes(method); + return new TestMethodModel( Name: method.Name, IsStatic: method.IsStatic, @@ -157,7 +160,63 @@ private static TestMethodModel BuildMethod(IMethodSymbol method) ReturnsValueTask: returnsValueTask, ReturnsVoid: returnsVoid, Parameters: BuildParameters(method), - Attributes: BuildAttributes(CollectInheritedAttributes(method))); + Attributes: BuildAttributes(inheritedAttributes), + DataRows: BuildDataRows(inheritedAttributes)); + } + + // Walks the attribute list and reifies each [DataRow(...)] application into a flat + // object?[] row. Mirrors DataRowAttribute's runtime behavior: when the constructor uses + // the variadic overload (object? data1, params object?[] moreData), Roslyn surfaces the + // tail as a single Array TypedConstant, which we flatten back so the consumer sees the + // same shape as DataRowAttribute.Data. + private static EquatableArray BuildDataRows(ImmutableArray attributes) + { + if (attributes.IsDefaultOrEmpty) + { + return EquatableArray.Empty; + } + + ImmutableArray.Builder builder = ImmutableArray.CreateBuilder(); + foreach (AttributeData attribute in attributes) + { + if (attribute.AttributeClass is not { } attributeClass) + { + continue; + } + + if (attributeClass.ToDisplayString(FullyQualifiedFormat) != "global::" + MSTestAttributeNames.DataRow) + { + continue; + } + + ImmutableArray ctorArgs = attribute.ConstructorArguments; + ImmutableArray.Builder rowBuilder = ImmutableArray.CreateBuilder(); + + bool lastIsParamsArray = + attribute.AttributeConstructor is { Parameters: { IsDefaultOrEmpty: false } parameters } + && parameters[parameters.Length - 1].IsParams + && !ctorArgs.IsDefaultOrEmpty + && ctorArgs[ctorArgs.Length - 1].Kind == TypedConstantKind.Array; + + for (int i = 0; i < ctorArgs.Length; i++) + { + if (i == ctorArgs.Length - 1 && lastIsParamsArray) + { + foreach (TypedConstant element in ctorArgs[i].Values) + { + rowBuilder.Add(ToModel(element)); + } + } + else + { + rowBuilder.Add(ToModel(ctorArgs[i])); + } + } + + builder.Add(new DataRowModel(new EquatableArray(rowBuilder.ToImmutable()))); + } + + return new EquatableArray(builder.ToImmutable()); } private static TestPropertyModel BuildProperty(IPropertySymbol property) diff --git a/src/Analyzers/MSTest.AotReflection.SourceGeneration/Model/TestClassModel.cs b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Model/TestClassModel.cs index db2818dfce..0be713a783 100644 --- a/src/Analyzers/MSTest.AotReflection.SourceGeneration/Model/TestClassModel.cs +++ b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Model/TestClassModel.cs @@ -41,6 +41,12 @@ internal enum ConstantValueKind internal sealed record TestParameterModel(string FullyQualifiedType, string Name); +/// +/// One row of arguments from a [DataRow] attribute, materialized at compile time so +/// the consumer can iterate without re-reading DataRowAttribute.Data via reflection. +/// +internal sealed record DataRowModel(EquatableArray Arguments); + internal sealed record TestMethodModel( string Name, bool IsStatic, @@ -49,7 +55,8 @@ internal sealed record TestMethodModel( bool ReturnsValueTask, bool ReturnsVoid, EquatableArray Parameters, - EquatableArray Attributes); + EquatableArray Attributes, + EquatableArray DataRows); internal sealed record TestPropertyModel( string Name, diff --git a/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTestReflectionMetadataGeneratorTests.cs b/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTestReflectionMetadataGeneratorTests.cs index 04a13d6624..5586f6c6dc 100644 --- a/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTestReflectionMetadataGeneratorTests.cs +++ b/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTestReflectionMetadataGeneratorTests.cs @@ -58,6 +58,13 @@ public class ParallelizeAttribute : System.Attribute public int Workers { get; set; } public string? Scope { get; set; } } + + [System.AttributeUsage(System.AttributeTargets.Method, AllowMultiple = true)] + public class DataRowAttribute : System.Attribute + { + public DataRowAttribute(object? data1) { } + public DataRowAttribute(object? data1, params object?[] moreData) { } + } } """; @@ -883,6 +890,148 @@ public class NotATest { } registry.Should().NotContain("new TestClassReflectionInfo("); } + [TestMethod] + public void Generator_EmitsEmptyDataRows_WhenMethodHasNoDataRow() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + [TestClass] + public class Tests + { + [TestMethod] + public void NoData() { } + } + } + """; + + GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode); + + result.Diagnostics.Should().BeEmpty(); + string registry = GetRegistry(result); + registry.Should().Contain("DataRows = Array.Empty()"); + registry.Should().NotContain("DataRows = new object?[][]"); + } + + [TestMethod] + public void Generator_CapturesSingleDataRow_WithScalarArgs() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + [TestClass] + public class Tests + { + [TestMethod] + [DataRow(1, "x")] + public void Test(int a, string b) { } + } + } + """; + + GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode); + + result.Diagnostics.Should().BeEmpty(); + string registry = GetRegistry(result); + registry.Should().Contain("DataRows = new object?[][]"); + registry.Should().Contain("new object?[] { 1, \"x\" }"); + } + + [TestMethod] + public void Generator_CapturesMultipleDataRows_InDeclarationOrder() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + [TestClass] + public class Tests + { + [TestMethod] + [DataRow(1, "a")] + [DataRow(2, "b")] + [DataRow(3, "c")] + public void Test(int a, string b) { } + } + } + """; + + GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode); + + result.Diagnostics.Should().BeEmpty(); + string registry = GetRegistry(result); + registry.Should().Contain("DataRows = new object?[][]"); + + int idx1 = registry.IndexOf("new object?[] { 1, \"a\" }", StringComparison.Ordinal); + int idx2 = registry.IndexOf("new object?[] { 2, \"b\" }", StringComparison.Ordinal); + int idx3 = registry.IndexOf("new object?[] { 3, \"c\" }", StringComparison.Ordinal); + + idx1.Should().BeGreaterThan(-1); + idx2.Should().BeGreaterThan(idx1); + idx3.Should().BeGreaterThan(idx2); + } + + [TestMethod] + public void Generator_FlattensParamsArrayInDataRow() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + [TestClass] + public class Tests + { + [TestMethod] + [DataRow(1, 2, 3, 4)] + public void Test(int a, int b, int c, int d) { } + } + } + """; + + GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode); + + result.Diagnostics.Should().BeEmpty(); + string registry = GetRegistry(result); + // The variadic `params object?[] moreData` tail must be flattened into a single flat row + // within the DataRows block — the row contains all four values inline, not nested. + registry.Should().Contain("new object?[] { 1, 2, 3, 4 }"); + } + + [TestMethod] + public void Generator_HandlesNullValueInDataRow() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + [TestClass] + public class Tests + { + [TestMethod] + [DataRow(null)] + public void Test(string? value) { } + } + } + """; + + GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode); + + result.Diagnostics.Should().BeEmpty(); + string registry = GetRegistry(result); + registry.Should().Contain("DataRows = new object?[][]"); + // The single-arg DataRowAttribute(object? data1) overload binds null to object, + // which surfaces as `(object)null!` from BuildConstantExpression (C# keyword form + // produced by FullyQualifiedFormat for System.Object). + registry.Should().Contain("new object?[] { (object)null! }"); + } + private static string GetRegistry(GeneratorRunResult result) => result.GeneratedSources .Single(s => s.HintName == "MSTestReflectionMetadata.Registry.g.cs") From 1d894a444211b5d7ff5644d191de39ab0e7c48e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Wed, 10 Jun 2026 17:01:45 +0200 Subject: [PATCH 6/8] A5: Make TestMethodReflectionInfo.Invoke async-aware (Task-returning) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of #1837. Changes the emitted `TestMethodReflectionInfo.Invoke` delegate from `Func` to `Func` so the caller can `await` a single Task regardless of the underlying test method's signature. Per return shape: - `void` / non-Task sync: `{ call; return Task.CompletedTask; }` — no allocation, no extra wrapping. - `Task` / `Task`: `{ Task? __t = call; return __t ?? Task.CompletedTask; }` — forward the Task; tolerate a misbehaving test returning `null` rather than NRE. - `ValueTask` / `ValueTask`: `{ var __vt = call; return __vt.IsCompletedSuccessfully ? Task.CompletedTask : __vt.AsTask(); }` — fast path for synchronous completion skips `AsTask()` allocation. - Non-void non-Task sync (e.g. `int Test()`): `{ _ = call; return Task.CompletedTask; }` — value discarded, side effects retained. Support type updated to declare `Invoke` as `Func` with default `static (_, _) => Task.CompletedTask`. Both the support-types file and the registry file now import `System.Threading.Tasks`. Also drops a duplicate blank line left over from PR-A4 (SA1507). Tests: - `Generator_SupportType_DeclaresInvokeAsTaskReturning` - `Generator_InvokerForVoidMethod_ReturnsCompletedTask` - `Generator_InvokerForTaskMethod_ForwardsTask` - `Generator_InvokerForTaskOfTMethod_ForwardsTask` - `Generator_InvokerForValueTaskMethod_UnwrapsViaAsTask` - `Generator_InvokerForValueTaskOfTMethod_UnwrapsViaAsTask` - `Generator_InvokerForNonVoidSyncMethod_DiscardsResultAndReturnsCompletedTask` - `Generator_EmittedRegistry_ImportsSystemThreadingTasks` Total: 40/40 passing (1 existing test updated for new shape). Depends on #9004, #9005, #9006, #9007, #9011. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Generators/MetadataRegistryEmitter.cs | 39 +++- .../MSTestReflectionMetadataGeneratorTests.cs | 187 +++++++++++++++++- 2 files changed, 219 insertions(+), 7 deletions(-) diff --git a/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/MetadataRegistryEmitter.cs b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/MetadataRegistryEmitter.cs index 50ad1b1703..efead40f59 100644 --- a/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/MetadataRegistryEmitter.cs +++ b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/MetadataRegistryEmitter.cs @@ -32,6 +32,7 @@ public static string EmitSupportTypes() sb.AppendLine("using System;"); sb.AppendLine("using System.Collections.Generic;"); + sb.AppendLine("using System.Threading.Tasks;"); sb.AppendLine(); using (sb.Block($"namespace {GeneratedNamespace}")) @@ -59,8 +60,8 @@ public static string EmitSupportTypes() sb.AppendLine("public Attribute[] Attributes { get; init; } = Array.Empty();"); sb.AppendLine("/// Materialized argument tuples from [DataRow] attributes (empty for non-data-driven tests). Each object?[] corresponds to one [DataRow] application."); sb.AppendLine("public IReadOnlyList DataRows { get; init; } = Array.Empty();"); - sb.AppendLine("/// Direct invoker — replaces ."); - sb.AppendLine("public Func Invoke { get; init; } = static (_, _) => null;"); + sb.AppendLine("/// Direct invoker — replaces . Always returns a non-null so the caller can await regardless of whether the underlying test method is void, Task, Task<T>, ValueTask, or ValueTask<T>; the result value (if any) is discarded."); + sb.AppendLine("public Func Invoke { get; init; } = static (_, _) => Task.CompletedTask;"); } sb.AppendLine(); @@ -92,6 +93,7 @@ public static string EmitRegistry(string assemblyName, AssemblyMetadataModel ass sb.AppendLine("using System;"); sb.AppendLine("using System.Collections.Generic;"); + sb.AppendLine("using System.Threading.Tasks;"); sb.AppendLine(); using (sb.Block($"namespace {GeneratedNamespace}")) @@ -277,9 +279,35 @@ private static void EmitMethodInvoker(IndentedStringBuilder sb, string classFqn, string args = BuildArgumentsFromObjectArray(method.Parameters); string call = $"{target}.{method.Name}({args})"; - string body = method.ReturnsVoid - ? $"{{ {call}; return null; }}" - : $"{{ return {call}; }}"; + // The contract is: return a non-null Task representing the (async or sync) completion of the + // test method, discarding any result value. This lets the caller use a single `await invoker(...)` + // path regardless of the underlying return shape. + // - void / non-Task sync: invoke, return Task.CompletedTask. + // - Task / Task: forward the returned Task (treat a `null` return as success). + // - ValueTask / ValueTask: avoid Task allocation for the synchronously-completed fast path + // via IsCompletedSuccessfully, otherwise call AsTask(). + string body; + if (method.ReturnsTask) + { + // Task derives from Task, so the same forwarding code handles both. A test method that + // *declares* a Task return type and then returns `null` is broken at runtime, but mirroring + // reflection-Invoke we tolerate it and treat it as already-completed. + body = $"{{ Task? __t = {call}; return __t ?? Task.CompletedTask; }}"; + } + else if (method.ReturnsValueTask) + { + body = $"{{ var __vt = {call}; return __vt.IsCompletedSuccessfully ? Task.CompletedTask : __vt.AsTask(); }}"; + } + else if (method.ReturnsVoid) + { + body = $"{{ {call}; return Task.CompletedTask; }}"; + } + else + { + // Non-void, non-Task return (e.g. `int Test()`). The test runner discards the value; we still + // execute the call for its side effects and report success. + body = $"{{ _ = {call}; return Task.CompletedTask; }}"; + } sb.AppendLine($"Invoke = static (instance, args) => {body},"); } @@ -321,7 +349,6 @@ private static void EmitDataRows(IndentedStringBuilder sb, EquatableArray parameters) { if (parameters.Length == 0) diff --git a/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTestReflectionMetadataGeneratorTests.cs b/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTestReflectionMetadataGeneratorTests.cs index 5586f6c6dc..ff8fe422c1 100644 --- a/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTestReflectionMetadataGeneratorTests.cs +++ b/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTestReflectionMetadataGeneratorTests.cs @@ -116,7 +116,7 @@ public void Test1() { } registry.Should().Contain("public const string AssemblyName = \"TestSample\";"); registry.Should().Contain("Type = typeof(global::Sample.MyTests)"); registry.Should().Contain("Name = \"Test1\""); - registry.Should().Contain("Invoke = static (instance, args) => { ((global::Sample.MyTests)instance!).Test1(); return null; },"); + registry.Should().Contain("Invoke = static (instance, args) => { ((global::Sample.MyTests)instance!).Test1(); return Task.CompletedTask; },"); } [TestMethod] @@ -1032,6 +1032,191 @@ public void Test(string? value) { } registry.Should().Contain("new object?[] { (object)null! }"); } + [TestMethod] + public void Generator_SupportType_DeclaresInvokeAsTaskReturning() + { + const string userCode = """ + // Empty consumer — we only care about the post-init support types. + """; + + GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode); + + result.Diagnostics.Should().BeEmpty(); + string support = result.GeneratedSources + .Single(s => s.HintName == "MSTestReflectionMetadata.SupportTypes.g.cs") + .SourceText.ToString(); + + support.Should().Contain("using System.Threading.Tasks;"); + // Invoke must be Task-returning so consumers can await without type-testing the result. + support.Should().Contain("public Func Invoke { get; init; } = static (_, _) => Task.CompletedTask;"); + } + + [TestMethod] + public void Generator_InvokerForVoidMethod_ReturnsCompletedTask() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + [TestClass] + public class Tests + { + [TestMethod] + public void SyncVoid() { } + } + } + """; + + string registry = GetRegistry(RunGenerator(MinimalMSTestStub, userCode)); + + registry.Should().Contain("Invoke = static (instance, args) => { ((global::Sample.Tests)instance!).SyncVoid(); return Task.CompletedTask; },"); + } + + [TestMethod] + public void Generator_InvokerForTaskMethod_ForwardsTask() + { + const string userCode = """ + using System.Threading.Tasks; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + [TestClass] + public class Tests + { + [TestMethod] + public Task AsyncTask() => Task.CompletedTask; + } + } + """; + + string registry = GetRegistry(RunGenerator(MinimalMSTestStub, userCode)); + + // Task and Task both forward via the same `Task? __t = …` path; null is tolerated so + // the invoker contract (non-null Task) holds even for a misbehaving test method. + registry.Should().Contain("Invoke = static (instance, args) => { Task? __t = ((global::Sample.Tests)instance!).AsyncTask(); return __t ?? Task.CompletedTask; },"); + } + + [TestMethod] + public void Generator_InvokerForTaskOfTMethod_ForwardsTask() + { + const string userCode = """ + using System.Threading.Tasks; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + [TestClass] + public class Tests + { + [TestMethod] + public Task AsyncTaskOfInt() => Task.FromResult(42); + } + } + """; + + string registry = GetRegistry(RunGenerator(MinimalMSTestStub, userCode)); + + registry.Should().Contain("Invoke = static (instance, args) => { Task? __t = ((global::Sample.Tests)instance!).AsyncTaskOfInt(); return __t ?? Task.CompletedTask; },"); + } + + [TestMethod] + public void Generator_InvokerForValueTaskMethod_UnwrapsViaAsTask() + { + const string userCode = """ + using System.Threading.Tasks; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + [TestClass] + public class Tests + { + [TestMethod] + public ValueTask AsyncValueTask() => default; + } + } + """; + + string registry = GetRegistry(RunGenerator(MinimalMSTestStub, userCode)); + + // ValueTask unwrap uses IsCompletedSuccessfully so the synchronous-completion fast path + // skips the Task allocation; only when the operation actually went async do we pay AsTask(). + registry.Should().Contain("Invoke = static (instance, args) => { var __vt = ((global::Sample.Tests)instance!).AsyncValueTask(); return __vt.IsCompletedSuccessfully ? Task.CompletedTask : __vt.AsTask(); },"); + } + + [TestMethod] + public void Generator_InvokerForValueTaskOfTMethod_UnwrapsViaAsTask() + { + const string userCode = """ + using System.Threading.Tasks; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + [TestClass] + public class Tests + { + [TestMethod] + public ValueTask AsyncValueTaskOfString() => new ValueTask("ok"); + } + } + """; + + string registry = GetRegistry(RunGenerator(MinimalMSTestStub, userCode)); + + registry.Should().Contain("Invoke = static (instance, args) => { var __vt = ((global::Sample.Tests)instance!).AsyncValueTaskOfString(); return __vt.IsCompletedSuccessfully ? Task.CompletedTask : __vt.AsTask(); },"); + } + + [TestMethod] + public void Generator_InvokerForNonVoidSyncMethod_DiscardsResultAndReturnsCompletedTask() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + [TestClass] + public class Tests + { + [TestMethod] + public int SyncInt() => 42; + } + } + """; + + string registry = GetRegistry(RunGenerator(MinimalMSTestStub, userCode)); + + // For a sync non-void test the returned value is discarded but the call must still execute + // (its side-effects ARE the test). We surface that with a `_ = call;` pattern. + registry.Should().Contain("Invoke = static (instance, args) => { _ = ((global::Sample.Tests)instance!).SyncInt(); return Task.CompletedTask; },"); + } + + [TestMethod] + public void Generator_EmittedRegistry_ImportsSystemThreadingTasks() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + [TestClass] + public class Tests + { + [TestMethod] + public void Test() { } + } + } + """; + + string registry = GetRegistry(RunGenerator(MinimalMSTestStub, userCode)); + + // The registry file references Task.CompletedTask directly in every invoker, so it must + // bring System.Threading.Tasks into scope. + registry.Should().Contain("using System.Threading.Tasks;"); + } + private static string GetRegistry(GeneratorRunResult result) => result.GeneratedSources .Single(s => s.HintName == "MSTestReflectionMetadata.Registry.g.cs") From 8b9414508cd314e70e93bbb1169dc1ccc588986d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Wed, 10 Jun 2026 17:38:39 +0200 Subject: [PATCH 7/8] Add analyzer diagnostics (AOTSG0001-AOTSG0005) for unsupported test-class shapes Adds analyzer-style diagnostics emitted by MSTest.AotReflection.SourceGeneration when it encounters a [TestClass] shape it cannot lower into the generated registry: - AOTSG0001 - static [TestClass] cannot be instantiated. - AOTSG0002 - open-generic [TestClass] (directly or via outer generic). - AOTSG0003 - inaccessible [TestClass] (file-local, private/protected nested, or nested in an inaccessible outer). - AOTSG0004 - generic [TestMethod] cannot be invoked without runtime instantiation. - AOTSG0005 - [TestMethod]/constructor parameter uses ef/in/out. When any of these fire, the offending type or member is skipped from the emitted registry instead of producing invalid C#. Adds 10 new tests covering positive and negative cases, plus AnalyzerReleases.{Shipped,Unshipped}.md to satisfy RS2008. Part of #1837. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AnalyzerReleases.Shipped.md | 2 + .../AnalyzerReleases.Unshipped.md | 12 + .../Diagnostics/DiagnosticDescriptors.cs | 78 +++++ .../Diagnostics/DiagnosticInfo.cs | 38 +++ .../Diagnostics/LocationInfo.cs | 46 +++ .../MSTestReflectionMetadataGenerator.cs | 126 +++++++- .../Generators/TestClassModelBuilder.cs | 54 +++- ...Test.AotReflection.SourceGeneration.csproj | 5 + .../MSTestReflectionMetadataGeneratorTests.cs | 284 +++++++++++++++++- 9 files changed, 626 insertions(+), 19 deletions(-) create mode 100644 src/Analyzers/MSTest.AotReflection.SourceGeneration/AnalyzerReleases.Shipped.md create mode 100644 src/Analyzers/MSTest.AotReflection.SourceGeneration/AnalyzerReleases.Unshipped.md create mode 100644 src/Analyzers/MSTest.AotReflection.SourceGeneration/Diagnostics/DiagnosticDescriptors.cs create mode 100644 src/Analyzers/MSTest.AotReflection.SourceGeneration/Diagnostics/DiagnosticInfo.cs create mode 100644 src/Analyzers/MSTest.AotReflection.SourceGeneration/Diagnostics/LocationInfo.cs diff --git a/src/Analyzers/MSTest.AotReflection.SourceGeneration/AnalyzerReleases.Shipped.md b/src/Analyzers/MSTest.AotReflection.SourceGeneration/AnalyzerReleases.Shipped.md new file mode 100644 index 0000000000..f50bb1fe21 --- /dev/null +++ b/src/Analyzers/MSTest.AotReflection.SourceGeneration/AnalyzerReleases.Shipped.md @@ -0,0 +1,2 @@ +; Shipped analyzer releases +; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md diff --git a/src/Analyzers/MSTest.AotReflection.SourceGeneration/AnalyzerReleases.Unshipped.md b/src/Analyzers/MSTest.AotReflection.SourceGeneration/AnalyzerReleases.Unshipped.md new file mode 100644 index 0000000000..72fe00128f --- /dev/null +++ b/src/Analyzers/MSTest.AotReflection.SourceGeneration/AnalyzerReleases.Unshipped.md @@ -0,0 +1,12 @@ +; Unshipped analyzer release +; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md + +### New Rules + +Rule ID | Category | Severity | Notes +--------|----------|----------|------- +AOTSG0001 | MSTest.AotReflection | Warning | StaticTestClass +AOTSG0002 | MSTest.AotReflection | Warning | GenericTestClass +AOTSG0003 | MSTest.AotReflection | Warning | InaccessibleTestClass +AOTSG0004 | MSTest.AotReflection | Warning | GenericTestMethod +AOTSG0005 | MSTest.AotReflection | Warning | ByRefParameter diff --git a/src/Analyzers/MSTest.AotReflection.SourceGeneration/Diagnostics/DiagnosticDescriptors.cs b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Diagnostics/DiagnosticDescriptors.cs new file mode 100644 index 0000000000..52c32f5127 --- /dev/null +++ b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Diagnostics/DiagnosticDescriptors.cs @@ -0,0 +1,78 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Collections.Generic; + +using Microsoft.CodeAnalysis; + +namespace MSTest.AotReflection.SourceGeneration.Diagnostics; + +/// +/// Catalogue of values surfaced by the AOT reflection +/// source generator when it encounters a [TestClass] shape it cannot materialize. +/// Each id is registered in AnalyzerReleases.Unshipped.md. +/// +internal static class DiagnosticDescriptors +{ + private const string Category = "MSTest.AotReflection"; + + public static readonly DiagnosticDescriptor StaticTestClass = new( + id: "AOTSG0001", + title: "Test class is static", + messageFormat: "[TestClass] type '{0}' is static and cannot be instantiated by the generated registry", + category: Category, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "Static test classes cannot be instantiated by the generated registry. Remove the 'static' modifier or use a non-static container."); + + public static readonly DiagnosticDescriptor GenericTestClass = new( + id: "AOTSG0002", + title: "Test class is generic", + messageFormat: "[TestClass] type '{0}' has unbound type parameters (either directly or via a generic outer type) and cannot be materialized as a closed type", + category: Category, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "Open generic test classes (or test classes nested inside a generic outer type) have no closed type at generation time. Use a concrete subclass that closes every type parameter."); + + public static readonly DiagnosticDescriptor InaccessibleTestClass = new( + id: "AOTSG0003", + title: "Test class is not accessible from generated code", + messageFormat: "[TestClass] type '{0}' is not reachable from generated code in the same assembly (file-local, or nested in a private/private-protected outer type)", + category: Category, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "Generated registry code lives in the same assembly but in a different file/type and therefore cannot reference file-local types or types nested in a private (or private-protected) outer type. Make the test class — and every enclosing type — at least internal."); + + public static readonly DiagnosticDescriptor GenericTestMethod = new( + id: "AOTSG0004", + title: "Test method is generic", + messageFormat: "Method '{0}.{1}' has type parameters which are not knowable at compile time; the source-generated invoker will skip it", + category: Category, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "Generic test methods would need a concrete type-argument list at the invocation site. Replace the method with one or more non-generic specializations."); + + public static readonly DiagnosticDescriptor ByRefParameter = new( + id: "AOTSG0005", + title: "Parameter uses a by-ref kind", + messageFormat: "Parameter '{2}' of '{0}.{1}' is declared with 'ref', 'in', or 'out' and cannot be passed through the 'object?[]' invoker; the member will be skipped", + category: Category, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "By-ref parameters cannot flow through the 'Func' invoker shape. Drop the ref/in/out modifier or move the dependency out of the test signature."); + + private static readonly Dictionary ById = new(StringComparer.Ordinal) + { + [StaticTestClass.Id] = StaticTestClass, + [GenericTestClass.Id] = GenericTestClass, + [InaccessibleTestClass.Id] = InaccessibleTestClass, + [GenericTestMethod.Id] = GenericTestMethod, + [ByRefParameter.Id] = ByRefParameter, + }; + + public static DiagnosticDescriptor GetById(string id) + => ById.TryGetValue(id, out DiagnosticDescriptor? descriptor) + ? descriptor + : throw new InvalidOperationException($"Unknown diagnostic id '{id}'."); +} diff --git a/src/Analyzers/MSTest.AotReflection.SourceGeneration/Diagnostics/DiagnosticInfo.cs b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Diagnostics/DiagnosticInfo.cs new file mode 100644 index 0000000000..06eda08594 --- /dev/null +++ b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Diagnostics/DiagnosticInfo.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Collections.Immutable; + +using Microsoft.CodeAnalysis; + +using MSTest.AotReflection.SourceGeneration.Model; + +namespace MSTest.AotReflection.SourceGeneration.Diagnostics; + +/// +/// Equatable payload that travels through the incremental-generator pipeline and is +/// reified into a real only at the RegisterSourceOutput +/// stage. Holding only the descriptor id (rather than the descriptor itself) keeps the +/// record cheaply equatable across runs. +/// +internal sealed record DiagnosticInfo( + string DescriptorId, + LocationInfo? Location, + EquatableArray MessageArgs) +{ + public Diagnostic ToDiagnostic() + { + DiagnosticDescriptor descriptor = DiagnosticDescriptors.GetById(DescriptorId); + ImmutableArray args = MessageArgs.AsImmutableArray(); + object?[] formattedArgs = new object?[args.Length]; + for (int i = 0; i < args.Length; i++) + { + formattedArgs[i] = args[i]; + } + + return Diagnostic.Create(descriptor, Location?.ToLocation() ?? Microsoft.CodeAnalysis.Location.None, formattedArgs); + } + + public static DiagnosticInfo Create(DiagnosticDescriptor descriptor, LocationInfo? location, params string[] messageArgs) + => new(descriptor.Id, location, new EquatableArray(ImmutableArray.Create(messageArgs))); +} diff --git a/src/Analyzers/MSTest.AotReflection.SourceGeneration/Diagnostics/LocationInfo.cs b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Diagnostics/LocationInfo.cs new file mode 100644 index 0000000000..b521ab17fd --- /dev/null +++ b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Diagnostics/LocationInfo.cs @@ -0,0 +1,46 @@ +// 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.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +namespace MSTest.AotReflection.SourceGeneration.Diagnostics; + +/// +/// Value-equatable surrogate for so a reported +/// can flow through incremental-generator pipelines +/// without breaking the model-equality contract that gates step caching. +/// +internal sealed record LocationInfo(string FilePath, TextSpan SourceSpan, LinePositionSpan LineSpan) +{ + public Location ToLocation() + => Location.Create(FilePath, SourceSpan, LineSpan); + + public static LocationInfo? CreateFrom(SyntaxNode node) + => CreateFrom(node.GetLocation()); + + public static LocationInfo? CreateFrom(ISymbol symbol) + { + foreach (SyntaxReference reference in symbol.DeclaringSyntaxReferences) + { + // The first declaration is the canonical one; partial declarations get the first + // physical occurrence which is good enough for diagnostic placement. + return CreateFrom(reference.GetSyntax().GetLocation()); + } + + return null; + } + + public static LocationInfo? CreateFrom(Location location) + { + if (location.SourceTree is null) + { + return null; + } + + return new LocationInfo( + location.SourceTree.FilePath, + location.SourceSpan, + location.GetLineSpan().Span); + } +} diff --git a/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/MSTestReflectionMetadataGenerator.cs b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/MSTestReflectionMetadataGenerator.cs index c0baa4025b..4267dbe718 100644 --- a/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/MSTestReflectionMetadataGenerator.cs +++ b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/MSTestReflectionMetadataGenerator.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. +using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; using System.Text; @@ -10,6 +11,7 @@ using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.CodeAnalysis.Text; +using MSTest.AotReflection.SourceGeneration.Diagnostics; using MSTest.AotReflection.SourceGeneration.Helpers; using MSTest.AotReflection.SourceGeneration.Model; @@ -35,15 +37,26 @@ public void Initialize(IncrementalGeneratorInitializationContext context) "MSTestReflectionMetadata.SupportTypes.g.cs", SourceText.From(MetadataRegistryEmitter.EmitSupportTypes(), Encoding.UTF8))); - IncrementalValuesProvider testClasses = context.SyntaxProvider + IncrementalValuesProvider rawResults = context.SyntaxProvider .ForAttributeWithMetadataName( MSTestAttributeNames.TestClass, - predicate: static (node, _) => - node is TypeDeclarationSyntax type - && !type.Modifiers.Any(m => m.IsKind(Microsoft.CodeAnalysis.CSharp.SyntaxKind.StaticKeyword)), - transform: static (ctx, ct) => BuildModel(ctx, ct)) - .Where(static model => model is not null) - .Select(static (model, _) => model!); + // Predicate stays cheap and shape-only. Diagnostics for unsupported shapes + // (static, generic, inaccessible, generic method, by-ref parameter) are + // computed in BuildModel where we have the full ISymbol. + predicate: static (node, _) => node is TypeDeclarationSyntax, + transform: static (ctx, ct) => BuildResult(ctx, ct)); + + // Surface every collected DiagnosticInfo as a real Diagnostic. Empty arrays produce + // no work, so this branch is allocation-free for clean compilations. + IncrementalValuesProvider diagnostics = rawResults + .SelectMany(static (result, _) => result.Diagnostics.AsImmutableArray()); + + context.RegisterSourceOutput(diagnostics, static (ctx, info) => + ctx.ReportDiagnostic(info.ToDiagnostic())); + + IncrementalValuesProvider testClasses = rawResults + .Where(static result => result.Model is not null) + .Select(static (result, _) => result.Model!); // Pull assembly-level attributes from the compilation (one value per run) and // wrap them in an equatable model so this branch of the pipeline can stay cached @@ -70,21 +83,108 @@ node is TypeDeclarationSyntax type }); } - private static TestClassModel? BuildModel(GeneratorAttributeSyntaxContext context, CancellationToken cancellationToken) + private static TestClassTransformResult BuildResult(GeneratorAttributeSyntaxContext context, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); if (context.TargetSymbol is not INamedTypeSymbol typeSymbol) { - return null; + return TestClassTransformResult.Empty; + } + + var diagnostics = new List(); + LocationInfo? classLocation = LocationInfo.CreateFrom(context.TargetNode); + + // Diagnostics that imply we cannot emit ANY model for this class. Reported in + // priority order — only the first matching reason is recorded so users aren't + // spammed with overlapping warnings (e.g. a static class is also "abstract" at the + // IL level, but AOTSG0001 is the only one that's actionable). + string fqn = typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + + if (typeSymbol.IsStatic) + { + diagnostics.Add(DiagnosticInfo.Create(DiagnosticDescriptors.StaticTestClass, classLocation, fqn)); + return new TestClassTransformResult(Model: null, Diagnostics: ToEquatable(diagnostics)); } - // Skip abstract / static / generic classes for this PoC — they need extra wiring. - if (typeSymbol.IsAbstract || typeSymbol.IsStatic || typeSymbol.IsGenericType) + if (IsGenericOrInsideGeneric(typeSymbol)) { - return null; + diagnostics.Add(DiagnosticInfo.Create(DiagnosticDescriptors.GenericTestClass, classLocation, fqn)); + return new TestClassTransformResult(Model: null, Diagnostics: ToEquatable(diagnostics)); } - return TestClassModelBuilder.Build(typeSymbol); + if (!IsReachableFromGeneratedCode(typeSymbol)) + { + diagnostics.Add(DiagnosticInfo.Create(DiagnosticDescriptors.InaccessibleTestClass, classLocation, fqn)); + return new TestClassTransformResult(Model: null, Diagnostics: ToEquatable(diagnostics)); + } + + // Abstract test classes stay silently filtered for now — they're a legitimate + // base-class pattern and the right UX needs the concrete-derived discovery from a + // future PR. + if (typeSymbol.IsAbstract) + { + return TestClassTransformResult.Empty; + } + + TestClassModel model = TestClassModelBuilder.Build(typeSymbol, diagnostics); + return new TestClassTransformResult(model, ToEquatable(diagnostics)); + } + + private static EquatableArray ToEquatable(List diagnostics) + => diagnostics.Count == 0 + ? EquatableArray.Empty + : new EquatableArray(diagnostics.ToImmutableArray()); + + private static bool IsGenericOrInsideGeneric(INamedTypeSymbol type) + { + for (INamedTypeSymbol? current = type; current is not null; current = current.ContainingType) + { + if (current.IsGenericType) + { + return true; + } + } + + return false; + } + + private static bool IsReachableFromGeneratedCode(INamedTypeSymbol type) + { + // The generated registry lives in the same assembly but in a different file/type, + // so it can reach Public / Internal / ProtectedOrInternal types (the latter being + // "protected internal" — visible from anywhere in the same assembly). Private, + // Protected (alone), and ProtectedAndInternal ("private protected") containing + // types make the type unreachable. + if (type.IsFileLocal) + { + return false; + } + + for (INamedTypeSymbol? current = type; current is not null; current = current.ContainingType) + { + if (current.IsFileLocal) + { + return false; + } + + switch (current.DeclaredAccessibility) + { + case Accessibility.Public: + case Accessibility.Internal: + case Accessibility.ProtectedOrInternal: + case Accessibility.NotApplicable: + continue; + default: + return false; + } + } + + return true; + } + + private sealed record TestClassTransformResult(TestClassModel? Model, EquatableArray Diagnostics) + { + public static readonly TestClassTransformResult Empty = new(null, EquatableArray.Empty); } } diff --git a/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/TestClassModelBuilder.cs b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/TestClassModelBuilder.cs index c5029b4100..5797846dac 100644 --- a/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/TestClassModelBuilder.cs +++ b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/TestClassModelBuilder.cs @@ -9,6 +9,7 @@ using Microsoft.CodeAnalysis; +using MSTest.AotReflection.SourceGeneration.Diagnostics; using MSTest.AotReflection.SourceGeneration.Helpers; using MSTest.AotReflection.SourceGeneration.Model; @@ -24,7 +25,7 @@ internal static class TestClassModelBuilder SymbolDisplayFormat.FullyQualifiedFormat.WithMiscellaneousOptions( SymbolDisplayMiscellaneousOptions.UseSpecialTypes); - public static TestClassModel Build(INamedTypeSymbol typeSymbol) + public static TestClassModel Build(INamedTypeSymbol typeSymbol, List diagnostics) { // Methods / properties are walked across the full inheritance chain (excluding // System.Object) so that MSTest members declared on a base class — @@ -41,6 +42,8 @@ public static TestClassModel Build(INamedTypeSymbol typeSymbol) ImmutableArray.Builder properties = ImmutableArray.CreateBuilder(); ImmutableArray.Builder ctors = ImmutableArray.CreateBuilder(); + string leafFqn = typeSymbol.ToDisplayString(FullyQualifiedFormat); + for (INamedTypeSymbol? current = typeSymbol; current is not null && current.SpecialType != SpecialType.System_Object; current = current.BaseType) @@ -53,6 +56,13 @@ public static TestClassModel Build(INamedTypeSymbol typeSymbol) { case IMethodSymbol { MethodKind: MethodKind.Ordinary } method when IsAccessibleFromConsumer(method): + if (TryReportUnsupportedMethod(method, leafFqn, diagnostics)) + { + // Skip generic / by-ref methods entirely so the emitter does not produce + // code that references unbound type parameters or ref/in/out arguments. + break; + } + string key = BuildMethodSignatureKey(method); if (!methodsByKey.ContainsKey(key)) { @@ -74,6 +84,11 @@ when IsAccessibleFromConsumer(property): break; case IMethodSymbol { MethodKind: MethodKind.Constructor, IsStatic: false } ctor when isLeaf && ctor.DeclaredAccessibility is Accessibility.Public or Accessibility.Internal: + if (TryReportUnsupportedMethod(ctor, leafFqn, diagnostics)) + { + break; + } + ctors.Add(new TestConstructorModel(BuildParameters(ctor))); break; } @@ -81,7 +96,7 @@ when IsAccessibleFromConsumer(property): } return new TestClassModel( - FullyQualifiedTypeName: typeSymbol.ToDisplayString(FullyQualifiedFormat), + FullyQualifiedTypeName: leafFqn, ContainingNamespace: typeSymbol.ContainingNamespace.IsGlobalNamespace ? string.Empty : typeSymbol.ContainingNamespace.ToDisplayString(), @@ -94,6 +109,41 @@ when IsAccessibleFromConsumer(property): Attributes: BuildAttributes(typeSymbol.GetAttributes())); } + // Reports AOTSG0004 (generic method) and AOTSG0005 (by-ref parameter) when applicable. + // Returns true if the member must be excluded from the emitted model. + private static bool TryReportUnsupportedMethod(IMethodSymbol method, string owningClassFqn, List diagnostics) + { + bool unsupported = false; + + // AOTSG0004 only applies to ordinary methods. Constructors cannot be generic so + // method.IsGenericMethod is false for them. + if (method.IsGenericMethod) + { + diagnostics.Add(DiagnosticInfo.Create( + DiagnosticDescriptors.GenericTestMethod, + LocationInfo.CreateFrom(method), + owningClassFqn, + method.Name)); + unsupported = true; + } + + foreach (IParameterSymbol parameter in method.Parameters) + { + if (parameter.RefKind != RefKind.None) + { + diagnostics.Add(DiagnosticInfo.Create( + DiagnosticDescriptors.ByRefParameter, + LocationInfo.CreateFrom(parameter), + owningClassFqn, + method.MethodKind == MethodKind.Constructor ? ".ctor" : method.Name, + parameter.Name)); + unsupported = true; + } + } + + return unsupported; + } + private static bool IsAccessibleFromConsumer(ISymbol symbol) => symbol.DeclaredAccessibility is Accessibility.Public diff --git a/src/Analyzers/MSTest.AotReflection.SourceGeneration/MSTest.AotReflection.SourceGeneration.csproj b/src/Analyzers/MSTest.AotReflection.SourceGeneration/MSTest.AotReflection.SourceGeneration.csproj index dc9bbade05..e988b20e1f 100644 --- a/src/Analyzers/MSTest.AotReflection.SourceGeneration/MSTest.AotReflection.SourceGeneration.csproj +++ b/src/Analyzers/MSTest.AotReflection.SourceGeneration/MSTest.AotReflection.SourceGeneration.csproj @@ -29,6 +29,11 @@ + + + + + diff --git a/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTestReflectionMetadataGeneratorTests.cs b/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTestReflectionMetadataGeneratorTests.cs index ff8fe422c1..1de711687f 100644 --- a/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTestReflectionMetadataGeneratorTests.cs +++ b/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTestReflectionMetadataGeneratorTests.cs @@ -158,9 +158,9 @@ public static void Test1() { } GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode); - result.Diagnostics.Should().BeEmpty(); + result.Diagnostics.Should().ContainSingle(d => d.Id == "AOTSG0001"); string registry = GetRegistry(result); - // Static classes are excluded by the predicate in the generator (cannot be instantiated). + // Static classes are excluded from the registry (cannot be instantiated) and reported via AOTSG0001. registry.Should().NotContain("StaticTests"); } @@ -216,9 +216,9 @@ public void Test1() { } GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode); - result.Diagnostics.Should().BeEmpty(); + result.Diagnostics.Should().ContainSingle(d => d.Id == "AOTSG0002"); string registry = GetRegistry(result); - // Open-generic test classes are out of scope for this PoC. + // Open-generic test classes are out of scope for this PoC and reported via AOTSG0002. registry.Should().NotContain("GenericTests"); } @@ -1217,6 +1217,282 @@ public void Test() { } registry.Should().Contain("using System.Threading.Tasks;"); } + [TestMethod] + public void Diagnostic_AOTSG0002_ReportedForNestedClassInsideGenericOuter() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + public class Outer + { + [TestClass] + public class InnerTests + { + [TestMethod] + public void Test1() { } + } + } + } + """; + + GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode); + + result.Diagnostics.Should().ContainSingle(d => d.Id == "AOTSG0002"); + string registry = GetRegistry(result); + registry.Should().NotContain("InnerTests"); + } + + [TestMethod] + public void Diagnostic_AOTSG0003_ReportedForFileLocalClass() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample; + + [TestClass] + file class FileLocalTests + { + [TestMethod] + public void Test1() { } + } + """; + + GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode); + + result.Diagnostics.Should().ContainSingle(d => d.Id == "AOTSG0003"); + string registry = GetRegistry(result); + registry.Should().NotContain("FileLocalTests"); + } + + [TestMethod] + public void Diagnostic_AOTSG0003_ReportedForPrivateNestedClass() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + public class Outer + { + [TestClass] + private class HiddenTests + { + [TestMethod] + public void Test1() { } + } + } + } + """; + + GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode); + + result.Diagnostics.Should().ContainSingle(d => d.Id == "AOTSG0003"); + string registry = GetRegistry(result); + registry.Should().NotContain("HiddenTests"); + } + + [TestMethod] + public void Diagnostic_AOTSG0003_NotReportedForInternalNestedInPublicOuter() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + public class Outer + { + [TestClass] + internal class VisibleTests + { + [TestMethod] + public void Test1() { } + } + } + } + """; + + GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode); + + result.Diagnostics.Should().BeEmpty(); + string registry = GetRegistry(result); + registry.Should().Contain("typeof(global::Sample.Outer.VisibleTests)"); + } + + [TestMethod] + public void Diagnostic_AOTSG0003_ReportedWhenOuterIsPrivateNested() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + public class Outer + { + private class HiddenOuter + { + [TestClass] + public class Tests + { + [TestMethod] + public void Test1() { } + } + } + } + } + """; + + GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode); + + result.Diagnostics.Should().ContainSingle(d => d.Id == "AOTSG0003"); + string registry = GetRegistry(result); + registry.Should().NotContain("typeof(global::Sample.Outer.HiddenOuter.Tests)"); + } + + [TestMethod] + public void Diagnostic_AOTSG0004_ReportedForGenericTestMethod_OtherMethodsStillEmitted() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + [TestClass] + public class Tests + { + [TestMethod] + public void GenericMethod() { } + + [TestMethod] + public void NormalMethod() { } + } + } + """; + + GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode); + + result.Diagnostics.Should().ContainSingle(d => d.Id == "AOTSG0004"); + string registry = GetRegistry(result); + // The class itself is still emitted because at least one supported member remains. + registry.Should().Contain("typeof(global::Sample.Tests)"); + // The generic method is excluded from the registry; the normal one is present. + registry.Should().NotContain("\"GenericMethod\""); + registry.Should().Contain("\"NormalMethod\""); + } + + [TestMethod] + public void Diagnostic_AOTSG0005_ReportedForByRefParameter() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + [TestClass] + public class Tests + { + [TestMethod] + public void RefParam(ref int x) { } + + [TestMethod] + public void NormalMethod() { } + } + } + """; + + GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode); + + result.Diagnostics.Should().ContainSingle(d => d.Id == "AOTSG0005"); + string registry = GetRegistry(result); + registry.Should().Contain("typeof(global::Sample.Tests)"); + registry.Should().NotContain("\"RefParam\""); + registry.Should().Contain("\"NormalMethod\""); + } + + [TestMethod] + public void Diagnostic_AOTSG0005_ReportedForOutAndInParameters() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + [TestClass] + public class Tests + { + [TestMethod] + public void OutParam(out int x) { x = 0; } + + [TestMethod] + public void InParam(in int x) { } + } + } + """; + + GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode); + + result.Diagnostics.Where(d => d.Id == "AOTSG0005").Should().HaveCount(2); + string registry = GetRegistry(result); + registry.Should().NotContain("\"OutParam\""); + registry.Should().NotContain("\"InParam\""); + } + + [TestMethod] + public void Diagnostic_AOTSG0005_ReportedForByRefConstructorParameter() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + [TestClass] + public class Tests + { + public Tests() { } + + public Tests(ref int x) { } + + [TestMethod] + public void Test1() { } + } + } + """; + + GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode); + + result.Diagnostics.Should().ContainSingle(d => d.Id == "AOTSG0005"); + string registry = GetRegistry(result); + // The valid parameterless constructor is still emitted. + registry.Should().Contain("typeof(global::Sample.Tests)"); + } + + [TestMethod] + public void Diagnostic_NoneReportedForWellFormedTestClass() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + [TestClass] + public class Tests + { + [TestMethod] + public void Test1() { } + + [TestMethod] + public void Test2(int x, string y) { } + } + } + """; + + GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode); + + result.Diagnostics.Should().BeEmpty(); + } + private static string GetRegistry(GeneratorRunResult result) => result.GeneratedSources .Single(s => s.HintName == "MSTestReflectionMetadata.Registry.g.cs") From 0a51e8a6bc861b7ae5586bdab2f94ea63d79a1a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Thu, 11 Jun 2026 10:07:06 +0200 Subject: [PATCH 8/8] Fix AOT reflection diagnostics review feedback Restrict generated registry member access to same-assembly non-derived accessibility, honor AttributeUsage when merging override attributes, and clarify AOTSG0003 wording. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Diagnostics/DiagnosticDescriptors.cs | 4 +- .../Generators/TestClassModelBuilder.cs | 67 +++++++++++--- .../MSTestReflectionMetadataGeneratorTests.cs | 90 +++++++++++++++++-- 3 files changed, 141 insertions(+), 20 deletions(-) diff --git a/src/Analyzers/MSTest.AotReflection.SourceGeneration/Diagnostics/DiagnosticDescriptors.cs b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Diagnostics/DiagnosticDescriptors.cs index 52c32f5127..381d55cc40 100644 --- a/src/Analyzers/MSTest.AotReflection.SourceGeneration/Diagnostics/DiagnosticDescriptors.cs +++ b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Diagnostics/DiagnosticDescriptors.cs @@ -38,11 +38,11 @@ internal static class DiagnosticDescriptors public static readonly DiagnosticDescriptor InaccessibleTestClass = new( id: "AOTSG0003", title: "Test class is not accessible from generated code", - messageFormat: "[TestClass] type '{0}' is not reachable from generated code in the same assembly (file-local, or nested in a private/private-protected outer type)", + messageFormat: "[TestClass] type '{0}' is not reachable from generated code in the same assembly (file-local, or nested in a non-public/non-internal outer type)", category: Category, defaultSeverity: DiagnosticSeverity.Warning, isEnabledByDefault: true, - description: "Generated registry code lives in the same assembly but in a different file/type and therefore cannot reference file-local types or types nested in a private (or private-protected) outer type. Make the test class — and every enclosing type — at least internal."); + description: "Generated registry code lives in the same assembly but in a different file/type and therefore cannot reference file-local types or types nested in a private, protected, or private-protected outer type. Make the test class — and every enclosing type — at least internal."); public static readonly DiagnosticDescriptor GenericTestMethod = new( id: "AOTSG0004", diff --git a/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/TestClassModelBuilder.cs b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/TestClassModelBuilder.cs index 5797846dac..ce11072188 100644 --- a/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/TestClassModelBuilder.cs +++ b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/TestClassModelBuilder.cs @@ -148,9 +148,7 @@ private static bool IsAccessibleFromConsumer(ISymbol symbol) => symbol.DeclaredAccessibility is Accessibility.Public or Accessibility.Internal - or Accessibility.Protected - or Accessibility.ProtectedOrInternal - or Accessibility.ProtectedAndInternal; + or Accessibility.ProtectedOrInternal; private static string BuildMethodSignatureKey(IMethodSymbol method) { @@ -277,8 +275,8 @@ private static TestPropertyModel BuildProperty(IPropertySymbol property) Attributes: BuildAttributes(CollectInheritedAttributes(property))); // Mirror the runtime behavior of MemberInfo.GetCustomAttributes(inherit: true): walk the - // overridden-method chain and union attributes, keeping the most-derived application when - // the same attribute type appears on multiple levels. + // overridden-member chain, honor AttributeUsageAttribute.Inherited, and keep only the + // most-derived application for attributes that do not allow multiple instances. private static ImmutableArray CollectInheritedAttributes(IMethodSymbol method) { ImmutableArray own = method.GetAttributes(); @@ -289,10 +287,10 @@ private static ImmutableArray CollectInheritedAttributes(IMethodS var seen = new HashSet(StringComparer.Ordinal); ImmutableArray.Builder builder = ImmutableArray.CreateBuilder(); - AppendUnique(builder, seen, own); + AppendAttributes(builder, seen, own, inheritedOnly: false); for (IMethodSymbol? baseMethod = method.OverriddenMethod; baseMethod is not null; baseMethod = baseMethod.OverriddenMethod) { - AppendUnique(builder, seen, baseMethod.GetAttributes()); + AppendAttributes(builder, seen, baseMethod.GetAttributes(), inheritedOnly: true); } return builder.ToImmutable(); @@ -308,19 +306,20 @@ private static ImmutableArray CollectInheritedAttributes(IPropert var seen = new HashSet(StringComparer.Ordinal); ImmutableArray.Builder builder = ImmutableArray.CreateBuilder(); - AppendUnique(builder, seen, own); + AppendAttributes(builder, seen, own, inheritedOnly: false); for (IPropertySymbol? baseProperty = property.OverriddenProperty; baseProperty is not null; baseProperty = baseProperty.OverriddenProperty) { - AppendUnique(builder, seen, baseProperty.GetAttributes()); + AppendAttributes(builder, seen, baseProperty.GetAttributes(), inheritedOnly: true); } return builder.ToImmutable(); } - private static void AppendUnique( + private static void AppendAttributes( ImmutableArray.Builder builder, HashSet seen, - ImmutableArray attributes) + ImmutableArray attributes, + bool inheritedOnly) { foreach (AttributeData attribute in attributes) { @@ -329,14 +328,58 @@ private static void AppendUnique( continue; } + AttributeUsageMetadata usage = GetAttributeUsage(attributeClass); + if (inheritedOnly && !usage.Inherited) + { + continue; + } + string key = attributeClass.ToDisplayString(FullyQualifiedFormat); - if (seen.Add(key)) + if (usage.AllowMultiple || seen.Add(key)) { builder.Add(attribute); } } } + private static AttributeUsageMetadata GetAttributeUsage(INamedTypeSymbol attributeClass) + { + bool inherited = true; + bool allowMultiple = false; + + foreach (AttributeData attribute in attributeClass.GetAttributes()) + { + if (attribute.AttributeClass?.ToDisplayString(FullyQualifiedFormat) != "global::System.AttributeUsageAttribute") + { + continue; + } + + foreach (KeyValuePair namedArgument in attribute.NamedArguments) + { + if (namedArgument.Value.Value is not bool value) + { + continue; + } + + switch (namedArgument.Key) + { + case nameof(AttributeUsageAttribute.Inherited): + inherited = value; + break; + case nameof(AttributeUsageAttribute.AllowMultiple): + allowMultiple = value; + break; + } + } + + break; + } + + return new AttributeUsageMetadata(inherited, allowMultiple); + } + + private readonly record struct AttributeUsageMetadata(bool Inherited, bool AllowMultiple); + private static EquatableArray BuildParameters(IMethodSymbol method) { if (method.Parameters.IsDefaultOrEmpty) diff --git a/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTestReflectionMetadataGeneratorTests.cs b/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTestReflectionMetadataGeneratorTests.cs index 1de711687f..d171e16458 100644 --- a/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTestReflectionMetadataGeneratorTests.cs +++ b/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTestReflectionMetadataGeneratorTests.cs @@ -28,7 +28,7 @@ namespace Microsoft.VisualStudio.TestTools.UnitTesting [System.AttributeUsage(System.AttributeTargets.Class)] public class TestClassAttribute : System.Attribute { } - [System.AttributeUsage(System.AttributeTargets.Method)] + [System.AttributeUsage(System.AttributeTargets.Method, Inherited = false)] public class TestMethodAttribute : System.Attribute { public TestMethodAttribute() { } @@ -36,7 +36,7 @@ public TestMethodAttribute() { } public string? DisplayName { get; set; } } - [System.AttributeUsage(System.AttributeTargets.Class | System.AttributeTargets.Method, AllowMultiple = true)] + [System.AttributeUsage(System.AttributeTargets.Class | System.AttributeTargets.Method, AllowMultiple = true, Inherited = true)] public class TestCategoryAttribute : System.Attribute { public TestCategoryAttribute(string category) { Category = category; } @@ -59,7 +59,7 @@ public class ParallelizeAttribute : System.Attribute public string? Scope { get; set; } } - [System.AttributeUsage(System.AttributeTargets.Method, AllowMultiple = true)] + [System.AttributeUsage(System.AttributeTargets.Method, AllowMultiple = true, Inherited = false)] public class DataRowAttribute : System.Attribute { public DataRowAttribute(object? data1) { } @@ -403,6 +403,51 @@ public void Sync(int x) { } "the generated source MUST compile cleanly when consumed in the same compilation as the user code"); } + [TestMethod] + public void Generator_SkipsProtectedMembers() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + public class TestContext { } + + [TestClass] + public class ProtectedShapes + { + [TestContext] + protected TestContext? Context { get; set; } + + [TestMethod] + protected void ProtectedTest() { } + + [TestMethod] + private protected void PrivateProtectedTest() { } + + [TestMethod] + protected internal void ProtectedInternalTest() { } + } + } + """; + + Compilation outputCompilation = RunGeneratorAndGetCompilation(MinimalMSTestStub, userCode); + string registry = outputCompilation + .SyntaxTrees + .Single(t => t.FilePath.EndsWith("MSTestReflectionMetadata.Registry.g.cs", System.StringComparison.Ordinal)) + .ToString(); + + registry.Should().NotContain("ProtectedTest"); + registry.Should().NotContain("PrivateProtectedTest"); + registry.Should().NotContain("Context"); + registry.Should().Contain("ProtectedInternalTest"); + + IEnumerable errors = outputCompilation + .GetDiagnostics() + .Where(d => d.Severity == DiagnosticSeverity.Error); + errors.Should().BeEmpty("the registry can only call members accessible from a non-derived type in the same assembly"); + } + [TestMethod] public void Generator_StripsNullableAnnotation_FromTypeofExpressions() { @@ -584,9 +629,42 @@ public override void Run() { } registry.Should().Contain("((global::Sample.DerivedTests)instance!).Run();"); registry.Should().NotContain("((global::Sample.BaseTests)instance!).Run();"); - // The override does NOT re-apply [TestMethod] but the attribute must still be visible - // via the override chain — matching the runtime semantics of GetCustomAttributes(inherit: true). - registry.Should().Contain("global::Microsoft.VisualStudio.TestTools.UnitTesting.TestMethodAttribute"); + // TestMethodAttribute is not inherited, so the override should not pick up the base attribute. + registry.Should().NotContain("global::Microsoft.VisualStudio.TestTools.UnitTesting.TestMethodAttribute"); + } + + [TestMethod] + public void Generator_OverriddenVirtualMethod_HonorsInheritedAttributeUsage() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + public class BaseTests + { + [TestMethod] + [TestCategory("Base")] + [DataRow(1)] + public virtual void Run(int value) { } + } + + [TestClass] + public class DerivedTests : BaseTests + { + [TestMethod] + [TestCategory("Derived")] + public override void Run(int value) { } + } + } + """; + + string registry = GetRegistry(RunGenerator(MinimalMSTestStub, userCode)); + + registry.Should().Contain("\"Base\""); + registry.Should().Contain("\"Derived\""); + registry.Should().Contain("DataRows = Array.Empty()"); + registry.Should().NotContain("new object?[] { 1 }"); } [TestMethod]