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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -255,9 +255,20 @@ private static void EmitProperties(IndentedStringBuilder sb, string fqn, TestCla
sb.AppendLine($"HasPublicSetter = {Bool(prop.HasPublicSetter)},");
EmitAttributesProperty(sb, "Attributes", prop.Attributes);
sb.AppendLine(",");
sb.AppendLine($"Get = static instance => instance is null ? null : (object?)(({fqn})instance).{prop.Name},");

// Static members are accessed through the type name; instance members through
// the cast receiver. Indexers are filtered out earlier because the name-based
// Get/Set delegate shape cannot represent them.
string getBody = prop.HasGettableValue
? prop.IsStatic
? $"(object?){fqn}.{prop.Name}"
: $"instance is null ? null : (object?)(({fqn})instance).{prop.Name}"
: $"throw new InvalidOperationException(\"Property '{prop.Name}' has no accessible getter.\")";
sb.AppendLine($"Get = static instance => {getBody},");

string setTarget = prop.IsStatic ? fqn : $"(({fqn})instance!)";
string setBody = prop.HasPublicSetter
? $"(({fqn})instance!).{prop.Name} = ({prop.FullyQualifiedType})value!"
? $"{setTarget}.{prop.Name} = ({prop.FullyQualifiedType})value!"
: $"throw new InvalidOperationException(\"Property '{prop.Name}' has no public setter.\")";
sb.AppendLine($"Set = static (instance, value) => {setBody},");
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ when IsAccessibleFromConsumer(method):

break;
case IPropertySymbol property
when IsAccessibleFromConsumer(property):
when !property.IsIndexer && IsAccessibleFromConsumer(property):
if (!propertiesByName.ContainsKey(property.Name))
{
TestPropertyModel model = BuildProperty(property);
Expand Down Expand Up @@ -278,6 +278,17 @@ private static TestPropertyModel BuildProperty(IPropertySymbol property)
=> new(
Name: property.Name,
FullyQualifiedType: property.Type.ToDisplayString(FullyQualifiedFormat),
IsStatic: property.IsStatic,

// The generated registry lives in the consuming assembly, so a getter is reachable
// when it is public, internal, or protected-internal. private / protected getters
// cannot be read from the generated (non-derived) call site.
HasGettableValue: property.GetMethod is
{
DeclaredAccessibility: Accessibility.Public
or Accessibility.Internal
or Accessibility.ProtectedOrInternal,
},
HasPublicSetter: property.SetMethod is { DeclaredAccessibility: Accessibility.Public },
Attributes: BuildAttributes(CollectInheritedAttributes(property)));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,15 @@
<ItemGroup>
<Compile Include="..\MSTest.SourceGeneration\Helpers\Constants.cs" Link="Helpers\Constants.cs" />
<Compile Include="..\MSTest.SourceGeneration\Helpers\IndentedStringBuilder.cs" Link="Helpers\IndentedStringBuilder.cs" />

<!-- Share the shipping generator's proven runtime-wiring source (ModuleInitializer + Register +
DynamicDependency rooting) so a project that references ONLY this AOT package is fully
functional today, on top of the reflection-free registry this generator also emits. This is
intentionally shared source (not a fork) so the wiring cannot drift from MSTest.SourceGeneration
until the reflection-free execution path is wired up and the two generators are consolidated. -->
<Compile Include="..\MSTest.SourceGeneration\Generators\ReflectionMetadataGenerator.cs" Link="Generators\ReflectionMetadataGenerator.cs" />
<Compile Include="..\MSTest.SourceGeneration\Emitters\ReflectionMetadataEmitter.cs" Link="Emitters\ReflectionMetadataEmitter.cs" />
<Compile Include="..\MSTest.SourceGeneration\Models\TestAssemblyMetadata.cs" Link="Models\TestAssemblyMetadata.cs" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ internal sealed record TestMethodModel(
internal sealed record TestPropertyModel(
string Name,
string FullyQualifiedType,
bool IsStatic,
bool HasGettableValue,
bool HasPublicSetter,
EquatableArray<AttributeApplicationModel> Attributes);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,21 @@ public DataRowAttribute(object? data1, params object?[] moreData) { }
}
""";

/// <summary>
/// Minimal stub of the adapter's runtime hook so the emitted <c>[ModuleInitializer]</c> wiring
/// (shared from MSTest.SourceGeneration) compiles in the Roslyn test compilation without
/// referencing MSTestAdapter.PlatformServices.
/// </summary>
private const string RuntimeHookStub = """
namespace Microsoft.VisualStudio.TestPlatform.MSTestAdapter.PlatformServices.SourceGeneration
{
public static class ReflectionMetadataHook
{
public static void Register(System.Reflection.Assembly assembly, System.Type[] types, System.Collections.Generic.IReadOnlyDictionary<System.Type, System.Reflection.MethodInfo[]> testMethods) { }
}
}
""";

[TestMethod]
public void Generator_EmitsSupportTypes_OnAnyCompilation()
{
Expand Down Expand Up @@ -364,6 +379,113 @@ public void Test1() { }
registry.Should().Contain("Set = static (instance, value) => ((global::Sample.PropTests)instance!).Context = (global::Sample.TestContext)value!,");
}

[TestMethod]
public void Generator_EmitsStaticPropertyAccess_WithoutInstanceCast()
{
const string userCode = """
using Microsoft.VisualStudio.TestTools.UnitTesting;

namespace Sample
{
[TestClass]
public class StaticProps
{
public static int Value { get; set; }

[TestMethod]
public void Test1() { }
}
}
""";

GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode);

result.Diagnostics.Should().BeEmpty();
string registry = GetRegistry(result);
registry.Should().Contain("Name = \"Value\"");

// A static member must be accessed through the type, never through an instance cast,
// otherwise the generated code fails to compile with CS0176.
registry.Should().Contain("Get = static instance => (object?)global::Sample.StaticProps.Value,");
registry.Should().Contain("Set = static (instance, value) => global::Sample.StaticProps.Value = (int)value!,");
registry.Should().NotContain("((global::Sample.StaticProps)instance).Value");
}

[TestMethod]
public void Generator_SkipsIndexerProperty()
{
const string userCode = """
using Microsoft.VisualStudio.TestTools.UnitTesting;

namespace Sample
{
[TestClass]
public class WithIndexer
{
public int this[int index] => index;

[TestMethod]
public void Test1() { }
}
}
""";

Compilation outputCompilation = RunGeneratorAndGetCompilation(MinimalMSTestStub, userCode);
string registry = outputCompilation
.SyntaxTrees
.Single(t => t.FilePath.EndsWith("MSTestReflectionMetadata.Registry.g.cs", System.StringComparison.Ordinal))
.ToString();

// Indexers cannot be represented by the name-based Get/Set delegate shape, so they are
// dropped entirely instead of emitting non-compiling `((T)instance).this[]` code.
registry.Should().NotContain("this[");
registry.Should().NotContain("Name = \"Item\"");

IEnumerable<Diagnostic> errors = outputCompilation
.GetDiagnostics()
.Where(d => d.Severity == DiagnosticSeverity.Error);
errors.Should().BeEmpty("indexers must be skipped so the emitted registry still compiles");
}

[TestMethod]
public void Generator_EmitsThrowingGetter_ForSetOnlyProperty()
{
const string userCode = """
using Microsoft.VisualStudio.TestTools.UnitTesting;

namespace Sample
{
[TestClass]
public class WriteOnly
{
private int _value;
public int Value { set { _value = value; } }

[TestMethod]
public void Test1() { }
}
}
""";

Compilation outputCompilation = RunGeneratorAndGetCompilation(MinimalMSTestStub, userCode);
string registry = outputCompilation
.SyntaxTrees
.Single(t => t.FilePath.EndsWith("MSTestReflectionMetadata.Registry.g.cs", System.StringComparison.Ordinal))
.ToString();

registry.Should().Contain("Name = \"Value\"");

// A set-only property has no readable getter; emitting `instance.Value` would not compile,
// so the Get delegate throws instead.
registry.Should().Contain("Get = static instance => throw new InvalidOperationException(\"Property 'Value' has no accessible getter.\"),");
registry.Should().Contain("((global::Sample.WriteOnly)instance!).Value = (int)value!");

IEnumerable<Diagnostic> errors = outputCompilation
.GetDiagnostics()
.Where(d => d.Severity == DiagnosticSeverity.Error);
errors.Should().BeEmpty("a set-only property must still produce a compiling registry");
}

[TestMethod]
public void Generator_EmittedSource_CompilesCleanly()
{
Expand Down Expand Up @@ -2002,6 +2124,49 @@ public void Test() { }
support.Should().NotContain("{ get; init; }");
}

[TestMethod]
public void WiringGenerator_EmitsModuleInitializer_RegisteringAssembly_AndCompilesAgainstHook()
{
const string userCode = """
using Microsoft.VisualStudio.TestTools.UnitTesting;

namespace Sample
{
[TestClass]
public class MyTests
{
[TestMethod]
public void Test1() { }
}
}
""";

// The AOT generator package shares MSTest.SourceGeneration's proven runtime-wiring
// generator so that referencing ONLY this package makes a test assembly discoverable and
// runnable today (via ReflectionMetadataHook.Register), in addition to emitting the
// reflection-free registry the future 0%-reflection path will consume.
var wiringGenerator = new Microsoft.VisualStudio.TestPlatform.MSTestAdapter.PlatformServices.SourceGeneration.Generators.ReflectionMetadataGenerator();
CSharpCompilation compilation = CreateCompilation(MinimalMSTestStub, RuntimeHookStub, userCode);
GeneratorDriver driver = CSharpGeneratorDriver.Create(wiringGenerator);
driver = driver.RunGeneratorsAndUpdateCompilation(compilation, out Compilation outputCompilation, out _);

GeneratorRunResult result = driver.GetRunResult().Results[0];
result.Diagnostics.Should().BeEmpty();

string wiring = result.GeneratedSources
.Single(s => s.HintName.EndsWith(".MSTestReflectionMetadata.g.cs", System.StringComparison.Ordinal))
.SourceText.ToString();

wiring.Should().Contain("[ModuleInitializer]");
wiring.Should().Contain("[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(global::Sample.MyTests))]");
wiring.Should().Contain(".ReflectionMetadataHook.Register(assembly, types, testMethods);");

IEnumerable<Diagnostic> errors = outputCompilation
.GetDiagnostics()
.Where(d => d.Severity == DiagnosticSeverity.Error);
errors.Should().BeEmpty("the emitted module initializer must compile against the adapter's ReflectionMetadataHook");
}

private static string GetRegistry(GeneratorRunResult result)
=> result.GeneratedSources
.Single(s => s.HintName == "MSTestReflectionMetadata.Registry.g.cs")
Expand Down
Loading