|
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using Aspire.Hosting.Ats;
namespace Aspire.Hosting.Tests.Ats;
[Trait("Partition", "4")]
public class AtsCapabilityScannerTests
{
#region MapToAtsTypeId Tests
[Fact]
public void MapToAtsTypeId_String_ReturnsString()
{
var result = AtsCapabilityScanner.MapToAtsTypeId(typeof(string));
Assert.Equal("string", result);
}
[Fact]
public void MapToAtsTypeId_Int32_ReturnsNumber()
{
var result = AtsCapabilityScanner.MapToAtsTypeId(typeof(int));
Assert.Equal("number", result);
}
[Fact]
public void MapToAtsTypeId_Boolean_ReturnsBoolean()
{
var result = AtsCapabilityScanner.MapToAtsTypeId(typeof(bool));
Assert.Equal("boolean", result);
}
[Fact]
public void MapToAtsTypeId_Void_ReturnsNull()
{
var result = AtsCapabilityScanner.MapToAtsTypeId(typeof(void));
Assert.Null(result);
}
[Fact]
public void MapToAtsTypeId_Task_ReturnsNull()
{
var result = AtsCapabilityScanner.MapToAtsTypeId(typeof(Task));
Assert.Null(result);
}
[Fact]
public void MapToAtsTypeId_TaskOfString_ReturnsString()
{
var result = AtsCapabilityScanner.MapToAtsTypeId(typeof(Task<string>));
Assert.Equal("string", result);
}
[Fact]
public void MapToAtsTypeId_TaskOfInt_ReturnsNumber()
{
var result = AtsCapabilityScanner.MapToAtsTypeId(typeof(Task<int>));
Assert.Equal("number", result);
}
[Fact]
public void MapToAtsTypeId_NullableInt_ReturnsNumber()
{
var result = AtsCapabilityScanner.MapToAtsTypeId(typeof(int?));
Assert.Equal("number", result);
}
[Fact]
public void MapToAtsTypeId_StringArray_ReturnsStringArray()
{
var result = AtsCapabilityScanner.MapToAtsTypeId(typeof(string[]));
Assert.Equal("string[]", result);
}
[Fact]
public void MapToAtsTypeId_IntArray_ReturnsNumberArray()
{
var result = AtsCapabilityScanner.MapToAtsTypeId(typeof(int[]));
Assert.Equal("number[]", result);
}
[Fact]
public void MapToAtsTypeId_IEnumerableOfString_ReturnsStringArray()
{
var result = AtsCapabilityScanner.MapToAtsTypeId(typeof(IEnumerable<string>));
Assert.Equal("string[]", result);
}
[Fact]
public void MapToAtsTypeId_IResourceBuilder_ExtractsResourceType()
{
var result = AtsCapabilityScanner.MapToAtsTypeId(typeof(IResourceBuilder<TestResource>));
// Should derive type ID from TestResource's full name
// Format: {AssemblyName}/{FullTypeName}
Assert.Equal("Aspire.Hosting.Tests/Aspire.Hosting.Tests.Ats.AtsCapabilityScannerTests+TestResource", result);
}
[Fact]
public void MapToAtsTypeId_UnknownType_ReturnsNull()
{
var result = AtsCapabilityScanner.MapToAtsTypeId(typeof(AtsCapabilityScannerTests));
// Unknown types return null (capabilities with unknown types are skipped)
Assert.Null(result);
}
[Fact]
public void MapToAtsTypeId_ObjectType_ReturnsAny()
{
var result = AtsCapabilityScanner.MapToAtsTypeId(typeof(object));
// System.Object maps to 'any'
Assert.Equal("any", result);
}
[Fact]
public void ScanAssembly_IEnumerableCapability_UsesArrayTypes()
{
var result = AtsCapabilityScanner.ScanAssembly(typeof(AtsCapabilityScannerTests).Assembly);
var enumerableParameterCapability = Assert.Single(result.Capabilities,
c => c.CapabilityId.EndsWith("/testEnumerableParameter", StringComparison.Ordinal));
var itemsParameter = Assert.Single(enumerableParameterCapability.Parameters);
var itemsType = Assert.IsType<AtsTypeRef>(itemsParameter.Type);
Assert.Equal("string[]", itemsType.TypeId);
Assert.Equal(AtsTypeCategory.Array, itemsType.Category);
var enumerableReturnCapability = Assert.Single(result.Capabilities,
c => c.CapabilityId.EndsWith("/testEnumerableReturn", StringComparison.Ordinal));
Assert.Equal("string[]", enumerableReturnCapability.ReturnType.TypeId);
Assert.Equal(AtsTypeCategory.Array, enumerableReturnCapability.ReturnType.Category);
}
#endregion
#region DeriveMethodName Tests
[Fact]
public void DeriveMethodName_SimpleCapabilityId_ReturnsMethodName()
{
var result = AtsCapabilityScanner.DeriveMethodName("Aspire.Hosting/createBuilder");
Assert.Equal("createBuilder", result);
}
[Fact]
public void DeriveMethodName_NestedCapabilityId_ReturnsMethodName()
{
var result = AtsCapabilityScanner.DeriveMethodName("Aspire.Hosting.Redis/addRedis");
Assert.Equal("addRedis", result);
}
[Fact]
public void DeriveMethodName_NoSlash_ReturnsEntireId()
{
var result = AtsCapabilityScanner.DeriveMethodName("withEnvironment");
Assert.Equal("withEnvironment", result);
}
#endregion
#region DerivePackage Tests
[Fact]
public void DerivePackage_SimpleCapabilityId_ReturnsPackage()
{
var result = AtsCapabilityScanner.DerivePackage("Aspire.Hosting/createBuilder");
Assert.Equal("Aspire.Hosting", result);
}
[Fact]
public void DerivePackage_NestedCapabilityId_ReturnsPackage()
{
var result = AtsCapabilityScanner.DerivePackage("Aspire.Hosting.Redis/addRedis");
Assert.Equal("Aspire.Hosting.Redis", result);
}
#endregion
#region Assembly-Level AspireExport Tests
[Fact]
public void ScanAssembly_AssemblyLevelExport_AppearsInHandleTypes()
{
// Regression test: assembly-level [AspireExport(typeof(T))] attributes must be
// discovered and included in HandleTypes so they participate in Unknown→Handle resolution.
// The Aspire.Hosting assembly exports CancellationToken at assembly level.
var hostingAssembly = typeof(DistributedApplication).Assembly;
var result = AtsCapabilityScanner.ScanAssembly(hostingAssembly);
// ContainerApp types are exported via assembly-level attributes in AppContainers,
// but CancellationToken is exported in Aspire.Hosting's AtsTypeMappings.cs
var cancellationTokenType = result.HandleTypes
.FirstOrDefault(t => t.AtsTypeId.Contains("CancellationToken"));
Assert.NotNull(cancellationTokenType);
}
#endregion
#region Callback Parameter Type Resolution Tests
[Fact]
public void ScanAssembly_MultiParamCallbackTypes_AreResolved()
{
// Regression test: callback parameter types must be resolved (not left as Unknown)
// when the types are exported. Previously only param.Type was resolved but not
// param.CallbackParameters[i].Type.
var testAssembly = typeof(AtsCapabilityScannerTests).Assembly;
var hostingAssembly = typeof(DistributedApplication).Assembly;
var result = AtsCapabilityScanner.ScanAssemblies([hostingAssembly, testAssembly]);
// Find the testMultiParamHandleCallback capability
var capability = result.Capabilities
.FirstOrDefault(c => c.CapabilityId.EndsWith("/testMultiParamHandleCallback", StringComparison.Ordinal));
Assert.NotNull(capability);
var callbackParam = Assert.Single(capability.Parameters, p => p.IsCallback);
Assert.NotNull(callbackParam.CallbackParameters);
Assert.Equal(2, callbackParam.CallbackParameters.Count);
// Both callback parameter types should be resolved to Handle (not Unknown)
foreach (var cbParam in callbackParam.CallbackParameters)
{
Assert.NotNull(cbParam.Type);
Assert.NotEqual(AtsTypeCategory.Unknown, cbParam.Type.Category);
}
}
[Fact]
public void ScanAssembly_YarpWithConfiguration_UsesBackgroundThreadOptIn()
{
var yarpAssembly = typeof(global::Aspire.Hosting.Yarp.YarpResource).Assembly;
var result = AtsCapabilityScanner.ScanAssembly(yarpAssembly);
var capability = Assert.Single(result.Capabilities,
c => c.CapabilityId.EndsWith("/withConfiguration", StringComparison.Ordinal));
var withConfigurationMethod = Assert.Single(result.Methods,
m => m.Key.EndsWith("/withConfiguration", StringComparison.Ordinal)).Value;
Assert.True(capability.RunSyncOnBackgroundThread);
Assert.Equal(typeof(IResourceBuilder<global::Aspire.Hosting.Yarp.YarpResource>), withConfigurationMethod.ReturnType);
var parameters = withConfigurationMethod.GetParameters();
Assert.Equal(2, parameters.Length);
Assert.Equal(typeof(IResourceBuilder<global::Aspire.Hosting.Yarp.YarpResource>), parameters[0].ParameterType);
Assert.Equal(typeof(Action<global::Aspire.Hosting.IYarpConfigurationBuilder>), parameters[1].ParameterType);
}
[Fact]
public void ScanAssembly_ClassLevelBackgroundThreadOptIn_AppliesToExportedMethods()
{
var result = AtsCapabilityScanner.ScanAssembly(typeof(AtsCapabilityScannerTests).Assembly);
var capability = Assert.Single(result.Capabilities,
c => c.CapabilityId.EndsWith("/classLevelBackgroundThreadProbe", StringComparison.Ordinal));
Assert.True(capability.RunSyncOnBackgroundThread);
}
#endregion
#region Test Types
private sealed class TestResource : Resource
{
public TestResource(string name) : base(name)
{
}
}
private static class TestExports
{
[AspireExport("testEnumerableParameter")]
public static void TestEnumerableParameter(IDistributedApplicationBuilder builder, IEnumerable<string> items)
{
_ = builder;
_ = items;
}
[AspireExport("testEnumerableReturn")]
public static IEnumerable<string> TestEnumerableReturn(IDistributedApplicationBuilder builder)
{
_ = builder;
return [];
}
[AspireExport("testMultiParamHandleCallback")]
public static IResourceBuilder<TestResource> TestMultiParamHandleCallback(
IResourceBuilder<TestResource> builder,
Func<ContainerResource, ProjectResource, Task> callback)
{
_ = callback;
return builder;
}
}
[AspireExport(RunSyncOnBackgroundThread = true)]
private static class ClassLevelBackgroundThreadExports
{
[AspireExport("classLevelBackgroundThreadProbe")]
public static void Probe(IDistributedApplicationBuilder builder)
{
_ = builder;
}
}
#endregion
#region XML Documentation Extraction Tests
[Fact]
public void GetXmlDocSummary_ReturnsNull_WhenDocIsNull()
{
var result = AtsCapabilityScanner.GetXmlDocSummary(null, "T:Some.Type");
Assert.Null(result);
}
[Fact]
public void GetXmlDocSummary_ReturnsNull_WhenMemberNotFound()
{
var doc = System.Xml.Linq.XDocument.Parse("""
<?xml version="1.0"?>
<doc>
<members>
<member name="T:Some.OtherType">
<summary>Other type.</summary>
</member>
</members>
</doc>
""");
var result = AtsCapabilityScanner.GetXmlDocSummary(doc, "T:Some.Type");
Assert.Null(result);
}
[Fact]
public void GetXmlDocSummary_ExtractsTypeSummary()
{
var doc = System.Xml.Linq.XDocument.Parse("""
<?xml version="1.0"?>
<doc>
<members>
<member name="T:Some.MyDto">
<summary>Options for creating a builder.</summary>
</member>
</members>
</doc>
""");
var result = AtsCapabilityScanner.GetXmlDocSummary(doc, "T:Some.MyDto");
Assert.Equal("Options for creating a builder.", result);
}
[Fact]
public void GetXmlDocSummary_ExtractsPropertySummary()
{
var doc = System.Xml.Linq.XDocument.Parse("""
<?xml version="1.0"?>
<doc>
<members>
<member name="P:Some.MyDto.Name">
<summary>The resource name.</summary>
</member>
</members>
</doc>
""");
var result = AtsCapabilityScanner.GetXmlDocSummary(doc, "P:Some.MyDto.Name");
Assert.Equal("The resource name.", result);
}
[Fact]
public void GetXmlDocSummary_NormalizesMultilineWhitespace()
{
var doc = System.Xml.Linq.XDocument.Parse("""
<?xml version="1.0"?>
<doc>
<members>
<member name="T:Some.MyDto">
<summary>
Options for creating
a distributed application builder.
</summary>
</member>
</members>
</doc>
""");
var result = AtsCapabilityScanner.GetXmlDocSummary(doc, "T:Some.MyDto");
Assert.Equal("Options for creating a distributed application builder.", result);
}
[Fact]
public void GetXmlDocSummary_ReturnsNull_WhenSummaryIsEmpty()
{
var doc = System.Xml.Linq.XDocument.Parse("""
<?xml version="1.0"?>
<doc>
<members>
<member name="T:Some.MyDto">
<summary> </summary>
</member>
</members>
</doc>
""");
var result = AtsCapabilityScanner.GetXmlDocSummary(doc, "T:Some.MyDto");
Assert.Null(result);
}
[Fact]
public void LoadXmlDocumentation_ReturnsCachedResult()
{
// Loading for the same assembly twice should return the same object
var assembly = typeof(DistributedApplication).Assembly;
var first = AtsCapabilityScanner.LoadXmlDocumentation(assembly);
var second = AtsCapabilityScanner.LoadXmlDocumentation(assembly);
Assert.NotNull(first);
Assert.Same(first, second);
}
#endregion
}
|