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..e1b0e1ff6c
--- /dev/null
+++ b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Diagnostics/DiagnosticDescriptors.cs
@@ -0,0 +1,75 @@
+// 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;
+
+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..9e224190e7
--- /dev/null
+++ b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Diagnostics/LocationInfo.cs
@@ -0,0 +1,41 @@
+// 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)
+ => location.SourceTree is null
+ ? null
+ : 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..73cd670254 100644
--- a/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/MSTestReflectionMetadataGenerator.cs
+++ b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/MSTestReflectionMetadataGenerator.cs
@@ -2,14 +2,12 @@
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
using System.Collections.Immutable;
-using System.Linq;
-using System.Text;
-using System.Threading;
using Microsoft.CodeAnalysis;
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 +33,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 BuildResult 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 +79,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();
+ var 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/MetadataRegistryEmitter.cs b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/MetadataRegistryEmitter.cs
index 50ad1b1703..d8a02d5593 100644
--- a/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/MetadataRegistryEmitter.cs
+++ b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/MetadataRegistryEmitter.cs
@@ -1,10 +1,6 @@
// 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.Globalization;
-using System.Linq;
-
using MSTest.AotReflection.SourceGeneration.Helpers;
using MSTest.AotReflection.SourceGeneration.Model;
@@ -32,6 +28,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}"))
@@ -39,46 +36,46 @@ public static string EmitSupportTypes()
sb.AppendLine("/// Describes one test class as discovered at compile-time. Mirrors what IReflectionOperations would return at runtime. ");
using (sb.Block("internal sealed class TestClassReflectionInfo"))
{
- sb.AppendLine("public Type Type { get; init; } = null!;");
- sb.AppendLine("public Attribute[] Attributes { get; init; } = Array.Empty();");
- sb.AppendLine("public IReadOnlyList Methods { get; init; } = Array.Empty();");
- sb.AppendLine("public IReadOnlyList Properties { get; init; } = Array.Empty();");
- sb.AppendLine("public IReadOnlyList Constructors { get; init; } = Array.Empty();");
+ sb.AppendLine("public Type Type { get; set; } = null!;");
+ sb.AppendLine("public Attribute[] Attributes { get; set; } = Array.Empty();");
+ sb.AppendLine("public IReadOnlyList Methods { get; set; } = Array.Empty();");
+ sb.AppendLine("public IReadOnlyList Properties { get; set; } = Array.Empty();");
+ sb.AppendLine("public IReadOnlyList Constructors { get; set; } = Array.Empty();");
}
sb.AppendLine();
using (sb.Block("internal sealed class TestMethodReflectionInfo"))
{
- sb.AppendLine("public string Name { get; init; } = string.Empty;");
- sb.AppendLine("public bool IsStatic { get; init; }");
- sb.AppendLine("public bool ReturnsTask { get; init; }");
- sb.AppendLine("public bool ReturnsValueTask { get; init; }");
- sb.AppendLine("public bool ReturnsVoid { get; init; }");
- 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("public string Name { get; set; } = string.Empty;");
+ sb.AppendLine("public bool IsStatic { get; set; }");
+ sb.AppendLine("public bool ReturnsTask { get; set; }");
+ sb.AppendLine("public bool ReturnsValueTask { get; set; }");
+ sb.AppendLine("public bool ReturnsVoid { get; set; }");
+ sb.AppendLine("public Type[] ParameterTypes { get; set; } = Array.Empty();");
+ sb.AppendLine("public string[] ParameterNames { get; set; } = Array.Empty();");
+ sb.AppendLine("public Attribute[] Attributes { get; set; } = 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("public IReadOnlyList DataRows { get; set; } = Array.Empty();");
+ 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; set; } = static (_, _) => Task.CompletedTask;");
}
sb.AppendLine();
using (sb.Block("internal sealed class TestPropertyReflectionInfo"))
{
- sb.AppendLine("public string Name { get; init; } = string.Empty;");
- sb.AppendLine("public Type PropertyType { get; init; } = typeof(object);");
- sb.AppendLine("public bool HasPublicSetter { get; init; }");
- sb.AppendLine("public Attribute[] Attributes { get; init; } = Array.Empty();");
- sb.AppendLine("public Func Get { get; init; } = static _ => null;");
- sb.AppendLine("public Action Set { get; init; } = static (_, _) => { };");
+ sb.AppendLine("public string Name { get; set; } = string.Empty;");
+ sb.AppendLine("public Type PropertyType { get; set; } = typeof(object);");
+ sb.AppendLine("public bool HasPublicSetter { get; set; }");
+ sb.AppendLine("public Attribute[] Attributes { get; set; } = Array.Empty();");
+ sb.AppendLine("public Func Get { get; set; } = static _ => null;");
+ sb.AppendLine("public Action Set { get; set; } = static (_, _) => { };");
}
sb.AppendLine();
using (sb.Block("internal sealed class TestConstructorReflectionInfo"))
{
- sb.AppendLine("public Type[] ParameterTypes { get; init; } = Array.Empty();");
- sb.AppendLine("public Func Invoke { get; init; } = static _ => throw new InvalidOperationException(\"No constructor registered.\");");
+ sb.AppendLine("public Type[] ParameterTypes { get; set; } = Array.Empty();");
+ sb.AppendLine("public Func Invoke { get; set; } = static _ => throw new InvalidOperationException(\"No constructor registered.\");");
}
}
@@ -92,6 +89,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 +275,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 +345,6 @@ private static void EmitDataRows(IndentedStringBuilder sb, EquatableArray parameters)
{
if (parameters.Length == 0)
@@ -455,15 +478,10 @@ private static string FormatPrimitive(object? value)
};
private static string BuildArgumentsFromObjectArray(EquatableArray parameters)
- {
- if (parameters.Length == 0)
- {
- return string.Empty;
- }
-
- return string.Join(", ", parameters.AsImmutableArray()
- .Select((p, i) => $"({p.FullyQualifiedType})args![{i}]!"));
- }
+ => parameters.Length == 0
+ ? string.Empty
+ : string.Join(", ", parameters.AsImmutableArray()
+ .Select((p, i) => $"({p.FullyQualifiedType})args![{i}]!"));
private static string Bool(bool value) => value ? "true" : "false";
diff --git a/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/TestClassModelBuilder.cs b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/TestClassModelBuilder.cs
index 26a4f4eea2..49893bbbe9 100644
--- a/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/TestClassModelBuilder.cs
+++ b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/TestClassModelBuilder.cs
@@ -1,14 +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;
+using MSTest.AotReflection.SourceGeneration.Diagnostics;
using MSTest.AotReflection.SourceGeneration.Helpers;
using MSTest.AotReflection.SourceGeneration.Model;
@@ -24,7 +21,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 +38,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 +52,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 +80,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 +92,7 @@ when IsAccessibleFromConsumer(property):
}
return new TestClassModel(
- FullyQualifiedTypeName: typeSymbol.ToDisplayString(FullyQualifiedFormat),
+ FullyQualifiedTypeName: leafFqn,
ContainingNamespace: typeSymbol.ContainingNamespace.IsGlobalNamespace
? string.Empty
: typeSymbol.ContainingNamespace.ToDisplayString(),
@@ -94,6 +105,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;
+ }
+
// Restricted to accessibilities the emitted helper class (a separate static type
// declared in MSTest.SourceGenerated, not a derived type) can legally call.
// 'protected' and 'private protected' members require the caller to be a derived
@@ -358,7 +404,7 @@ private static EquatableArray BuildParameters(IMethodSymbol
return EquatableArray.Empty;
}
- TestParameterModel[] parameters = new TestParameterModel[method.Parameters.Length];
+ var parameters = new TestParameterModel[method.Parameters.Length];
for (int i = 0; i < method.Parameters.Length; i++)
{
IParameterSymbol p = method.Parameters[i];
@@ -370,17 +416,12 @@ private static EquatableArray BuildParameters(IMethodSymbol
public static EquatableArray BuildAttributes(
ImmutableArray attributes)
- {
- if (attributes.IsDefaultOrEmpty)
- {
- return EquatableArray.Empty;
- }
-
- return attributes
- .Select(BuildAttribute)
- .WhereNotNull()
- .ToEquatableArray();
- }
+ => attributes.IsDefaultOrEmpty
+ ? EquatableArray.Empty
+ : attributes
+ .Select(BuildAttribute)
+ .WhereNotNull()
+ .ToEquatableArray();
private static AttributeApplicationModel? BuildAttribute(AttributeData attribute)
{
@@ -399,34 +440,29 @@ public static EquatableArray BuildAttributes(
}
private static TypedConstantModel ToModel(TypedConstant constant)
- {
- if (constant.IsNull)
- {
- return new TypedConstantModel(ConstantValueKind.Null, constant.Type?.ToDisplayString(FullyQualifiedFormat), null, EquatableArray.Empty);
- }
-
- return constant.Kind switch
- {
- TypedConstantKind.Array => new TypedConstantModel(
- ConstantValueKind.Array,
- constant.Type?.ToDisplayString(FullyQualifiedFormat),
- null,
- constant.Values.Select(ToModel).ToEquatableArray()),
- TypedConstantKind.Enum => new TypedConstantModel(
- ConstantValueKind.Enum,
- constant.Type?.ToDisplayString(FullyQualifiedFormat),
- constant.Value,
- EquatableArray.Empty),
- TypedConstantKind.Type => new TypedConstantModel(
- ConstantValueKind.Type,
- (constant.Value as ITypeSymbol)?.ToDisplayString(FullyQualifiedFormat),
- null,
- EquatableArray.Empty),
- _ => new TypedConstantModel(
- ConstantValueKind.Primitive,
- constant.Type?.ToDisplayString(FullyQualifiedFormat),
- constant.Value,
- EquatableArray.Empty),
- };
- }
+ => constant.IsNull
+ ? new TypedConstantModel(ConstantValueKind.Null, constant.Type?.ToDisplayString(FullyQualifiedFormat), null, EquatableArray.Empty)
+ : constant.Kind switch
+ {
+ TypedConstantKind.Array => new TypedConstantModel(
+ ConstantValueKind.Array,
+ constant.Type?.ToDisplayString(FullyQualifiedFormat),
+ null,
+ constant.Values.Select(ToModel).ToEquatableArray()),
+ TypedConstantKind.Enum => new TypedConstantModel(
+ ConstantValueKind.Enum,
+ constant.Type?.ToDisplayString(FullyQualifiedFormat),
+ constant.Value,
+ EquatableArray.Empty),
+ TypedConstantKind.Type => new TypedConstantModel(
+ ConstantValueKind.Type,
+ (constant.Value as ITypeSymbol)?.ToDisplayString(FullyQualifiedFormat),
+ null,
+ EquatableArray.Empty),
+ _ => new TypedConstantModel(
+ ConstantValueKind.Primitive,
+ constant.Type?.ToDisplayString(FullyQualifiedFormat),
+ constant.Value,
+ EquatableArray.Empty),
+ };
}
diff --git a/src/Analyzers/MSTest.AotReflection.SourceGeneration/Helpers/IndentedStringBuilder.cs b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Helpers/IndentedStringBuilder.cs
index 0690bc2db5..6899b7cce4 100644
--- a/src/Analyzers/MSTest.AotReflection.SourceGeneration/Helpers/IndentedStringBuilder.cs
+++ b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Helpers/IndentedStringBuilder.cs
@@ -1,10 +1,6 @@
// 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.Text;
-
namespace MSTest.AotReflection.SourceGeneration.Helpers;
///
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/src/Analyzers/MSTest.AotReflection.SourceGeneration/Model/TestClassModel.cs b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Model/TestClassModel.cs
index 0be713a783..4fe48c4ec5 100644
--- a/src/Analyzers/MSTest.AotReflection.SourceGeneration/Model/TestClassModel.cs
+++ b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Model/TestClassModel.cs
@@ -1,10 +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;
-using System.Collections.Generic;
using System.Collections.Immutable;
-using System.Linq;
namespace MSTest.AotReflection.SourceGeneration.Model;
@@ -90,6 +87,7 @@ internal sealed record TestClassModel(
/// Value-equatable wrapper around so incremental generation
/// can cache results between runs. Kept minimal — we don't need indexing in this PoC.
///
+/// The element type.
internal readonly struct EquatableArray : IEquatable>
where T : IEquatable
{
diff --git a/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTestReflectionMetadataGeneratorTests.cs b/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTestReflectionMetadataGeneratorTests.cs
index cfbe9236b1..9bba2f4ae8 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]
@@ -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");
}
@@ -518,6 +518,58 @@ public void DerivedTest() { }
registry.Should().Contain("global::Microsoft.VisualStudio.TestTools.UnitTesting.TestInitializeAttribute");
}
+ [TestMethod]
+ public void Generator_ExcludesProtectedAndPrivateProtectedMembersFromBaseType()
+ {
+ const string userCode = """
+ using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+ namespace Sample
+ {
+ public class TestContext { }
+
+ public class BaseTests
+ {
+ [TestMethod]
+ protected void ProtectedTest() { }
+
+ [TestMethod]
+ private protected void PrivateProtectedTest() { }
+
+ [TestMethod]
+ protected internal void ProtectedInternalTest() { }
+
+ [TestContext]
+ protected TestContext ProtectedContext { get; set; } = new();
+
+ [TestContext]
+ private protected TestContext PrivateProtectedContext { get; set; } = new();
+
+ [TestContext]
+ protected internal TestContext ProtectedInternalContext { get; set; } = new();
+ }
+
+ [TestClass]
+ public class DerivedTests : BaseTests
+ {
+ [TestMethod]
+ public void Test() { }
+ }
+ }
+ """;
+
+ GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode);
+
+ result.Diagnostics.Should().BeEmpty();
+ string registry = GetRegistry(result);
+ registry.Should().NotContain("Name = \"ProtectedTest\"");
+ registry.Should().NotContain("Name = \"PrivateProtectedTest\"");
+ registry.Should().NotContain("Name = \"ProtectedContext\"");
+ registry.Should().NotContain("Name = \"PrivateProtectedContext\"");
+ registry.Should().Contain("Name = \"ProtectedInternalTest\"");
+ registry.Should().Contain("Name = \"ProtectedInternalContext\"");
+ }
+
[TestMethod]
public void Generator_IncludesMethodsFromMultiLevelInheritance()
{
@@ -1111,81 +1163,530 @@ public override void Run() { }
}
[TestMethod]
- public void Generator_DistinguishesGenericArity_BetweenSameNamedMethods()
+ public void Generator_DoesNotInherit_TestMethodOrDataRow_OntoOverride()
+ {
+ // [TestMethod] and [DataRow] are declared with AttributeUsage(Inherited = false) in
+ // the real MSTest assembly. An override that does NOT re-apply [TestMethod] is not a
+ // test, and inherited [DataRow]s must not leak from the base method onto the override.
+ const string userCode = """
+ using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+ namespace Sample
+ {
+ public class BaseTests
+ {
+ [TestMethod]
+ [DataRow(1)]
+ [DataRow(2)]
+ public virtual void Run(int x) { }
+ }
+
+ [TestClass]
+ public class DerivedTests : BaseTests
+ {
+ // Override deliberately does not re-apply [TestMethod] or any [DataRow].
+ public override void Run(int x) { }
+ }
+ }
+ """;
+
+ string registry = GetRegistry(RunGenerator(MinimalMSTestStub, userCode));
+
+ // Neither the [TestMethod] nor the inherited [DataRow]s should propagate onto the override.
+ registry.Should().NotContain("Microsoft.VisualStudio.TestTools.UnitTesting.TestMethodAttribute()");
+ registry.Should().NotContain("Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute(");
+ registry.Should().NotContain("new DataRowModel(");
+ }
+
+ [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.
+ // Uses `set` (not `init`) per the repo guideline that new public-shaped API must not use init accessors.
+ support.Should().Contain("public Func Invoke { get; set; } = static (_, _) => Task.CompletedTask;");
+ }
+
+ [TestMethod]
+ public void Generator_InvokerForVoidMethod_ReturnsCompletedTask()
{
- // Methods that differ only in generic arity (e.g. M() vs M()) MUST be treated as
- // distinct in the per-class dedup key, otherwise the generator drops one of them.
const string userCode = """
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace Sample
{
[TestClass]
- public class GenericArityTests
+ public class Tests
{
[TestMethod]
- public void Run() { }
+ 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 void Run() { }
+ 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 void Run() { }
+ public Task AsyncTaskOfInt() => Task.FromResult(42);
}
}
""";
- Compilation outputCompilation = RunGeneratorAndGetCompilation(MinimalMSTestStub, userCode);
- IEnumerable errors = outputCompilation
- .GetDiagnostics()
- .Where(d => d.Severity == DiagnosticSeverity.Error);
- errors.Should().BeEmpty();
+ string registry = GetRegistry(RunGenerator(MinimalMSTestStub, userCode));
- string registry = outputCompilation
- .SyntaxTrees
- .Single(t => t.FilePath.EndsWith("MSTestReflectionMetadata.Registry.g.cs", System.StringComparison.Ordinal))
- .ToString();
+ 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;
- int occurrences = System.Text.RegularExpressions.Regex
- .Matches(registry, "Name = \"Run\"")
- .Count;
- occurrences.Should().Be(3);
+ 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_DoesNotInherit_TestMethodOrDataRow_OntoOverride()
+ public void Generator_InvokerForValueTaskOfTMethod_UnwrapsViaAsTask()
{
- // [TestMethod] and [DataRow] are declared with AttributeUsage(Inherited = false) in
- // the real MSTest assembly. An override that does NOT re-apply [TestMethod] is not a
- // test, and inherited [DataRow]s must not leak from the base method onto the override.
const string userCode = """
+ using System.Threading.Tasks;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace Sample
{
- public class BaseTests
+ [TestClass]
+ public class Tests
{
[TestMethod]
- [DataRow(1)]
- [DataRow(2)]
- public virtual void Run(int x) { }
+ 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 DerivedTests : BaseTests
+ public class Tests
{
- // Override deliberately does not re-apply [TestMethod] or any [DataRow].
- public override void Run(int x) { }
+ [TestMethod]
+ public int SyncInt() => 42;
}
}
""";
string registry = GetRegistry(RunGenerator(MinimalMSTestStub, userCode));
- // Neither the [TestMethod] nor the inherited [DataRow]s should propagate onto the override.
- registry.Should().NotContain("Microsoft.VisualStudio.TestTools.UnitTesting.TestMethodAttribute()");
- registry.Should().NotContain("Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute(");
- registry.Should().NotContain("new DataRowModel(");
+ // 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;");
+ }
+
+ [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();
+ }
+
+ [TestMethod]
+ public void Generator_SupportTypes_DoNotUseInitAccessors()
+ {
+ const string userCode = """
+ using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+ namespace Sample
+ {
+ [TestClass]
+ public class Tests
+ {
+ [TestMethod]
+ public void Test() { }
+ }
+ }
+ """;
+
+ GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode);
+
+ string support = result.GeneratedSources
+ .Single(s => s.HintName == "MSTestReflectionMetadata.SupportTypes.g.cs")
+ .SourceText.ToString();
+
+ // Repo guideline: newly introduced public-shaped API (even when emitted as `internal sealed`
+ // into the consumer assembly) MUST NOT use `init` accessors. Guard against accidental
+ // reintroduction.
+ support.Should().NotContain("{ get; init; }");
}
private static string GetRegistry(GeneratorRunResult result)