// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
#pragma warning disable ASPIREBROWSERLOGS001 // Type is for evaluation purposes only
using System.Reflection;
using System.Text.RegularExpressions;
using Aspire.Hosting.Azure;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.RemoteHost;
using Aspire.TypeSystem;
using Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes;
using Azure.Provisioning.AppContainers;
using Azure.Provisioning.AppService;
namespace Aspire.Hosting.CodeGeneration.TypeScript.Tests;
public class AtsTypeScriptCodeGeneratorTests
{
private readonly AtsTypeScriptCodeGenerator _generator = new();
[Fact]
public void Language_ReturnsTypeScript()
{
Assert.Equal("TypeScript", _generator.Language);
}
[Fact]
public void EmbeddedResource_PackageJson_IsAvailableWithExpectedStructure()
{
// The package.json under Resources/ is the single source of truth for
// the SDK manifest emitted alongside generated TypeScript. Verify the
// embedded resource loads and has the structural fields downstream
// consumers rely on — without copying its bytes into a snapshot file
// that would drift from the resource on every edit.
var content = EmbeddedResources.Read("package.json");
Assert.NotEmpty(content);
var packageJson = System.Text.Json.Nodes.JsonNode.Parse(content)!.AsObject();
Assert.Equal("aspire-host", packageJson["name"]?.GetValue<string>());
Assert.Equal("module", packageJson["type"]?.GetValue<string>());
Assert.NotNull(packageJson["dependencies"]?["vscode-jsonrpc"]);
}
[Fact]
public void GenerateDistributedApplication_EmitsBaseAndTransportResourcesVerbatim()
{
var atsContext = CreateContextFromTestAssembly();
var files = _generator.GenerateDistributedApplication(atsContext);
Assert.Contains("base.mts", files.Keys);
Assert.Contains("transport.mts", files.Keys);
// base.mts and transport.mts are emitted as embedded-resource pass-throughs,
// so asserting equality against the embedded resource (the single source
// of truth) keeps the test signal — "the generator emits the resource
// verbatim" — without maintaining duplicate *.verified.ts snapshots that
// would have to be regenerated on every change to the source resource.
Assert.Equal(EmbeddedResources.Read("base.mts"), files["base.mts"]);
Assert.Equal(EmbeddedResources.Read("transport.mts"), files["transport.mts"]);
}
[Fact]
public async Task GenerateDistributedApplication_WithTestTypes_GeneratesCorrectOutput()
{
// Arrange
var atsContext = CreateContextFromTestAssembly();
// Act
var files = _generator.GenerateDistributedApplication(atsContext);
Assert.Contains("aspire.mts", files.Keys);
// aspire.mts is real generated code (composed from scanned types), so a
// Verify snapshot is the right tool here. base.mts and transport.mts are
// resource pass-throughs and are covered by
// GenerateDistributedApplication_EmitsBaseAndTransportResourcesVerbatim.
await Verify(files["aspire.mts"], extension: "ts")
.UseFileName("AtsGeneratedAspire");
}
[Fact]
public void GenerateDistributedApplication_WithTestTypes_IncludesExportedValues()
{
var atsContext = CreateContextFromTestAssembly();
Assert.Contains(atsContext.ExportedValues, value => string.Join(".", value.PathSegments) == "TestConfigs.Default");
Assert.Contains(atsContext.ExportedValues, value => string.Join(".", value.PathSegments) == "TestConfigs.Profiles.Development");
var files = _generator.GenerateDistributedApplication(atsContext);
var aspireTs = files["aspire.mts"];
Assert.Contains("export namespace TestConfigs", aspireTs);
Assert.Contains("export const Default", aspireTs);
Assert.Contains("export namespace Profiles", aspireTs);
Assert.Contains("export const Development", aspireTs);
}
[Fact]
public void GenerateDistributedApplication_WithHostingTypes_KeepsReferenceExpressionInBaseTs()
{
var atsContext = CreateContextFromBothAssemblies();
var files = _generator.GenerateDistributedApplication(atsContext);
var aspireTs = files["aspire.mts"];
Assert.DoesNotContain("export class ReferenceExpression {", aspireTs);
Assert.Contains("export class ReferenceExpression {", files["base.mts"]);
Assert.Contains("registerHandleWrapper('Aspire.Hosting/Aspire.Hosting.ApplicationModel.ReferenceExpression'", files["base.mts"]);
Assert.Contains("condition: extractHandleForExpr(state.condition),", files["base.mts"]);
Assert.Contains("('$handle' in json || '$expr' in json)", files["base.mts"]);
Assert.Contains("registerCancellation(state.client, cancellationToken)", files["base.mts"]);
Assert.Contains("arguments(): InteractionInputCollectionPromise", aspireTs);
Assert.DoesNotContain("setArguments", aspireTs);
}
[Fact]
public void GenerateDistributedApplication_WithTestTypes_IncludesCapabilities()
{
// Arrange
var capabilities = ScanCapabilitiesFromTestAssembly();
// Assert that capabilities are discovered
Assert.NotEmpty(capabilities);
// Check for specific capabilities (now uses AssemblyName/methodName format)
Assert.Contains(capabilities, c => c.CapabilityId == "Aspire.Hosting.CodeGeneration.TypeScript.Tests/addTestRedis");
Assert.Contains(capabilities, c => c.CapabilityId == "Aspire.Hosting.CodeGeneration.TypeScript.Tests/withPersistence");
Assert.Contains(capabilities, c => c.CapabilityId == "Aspire.Hosting.CodeGeneration.TypeScript.Tests/withOptionalString");
}
[Fact]
public void GenerateDistributedApplication_WithTestTypes_DeriveCorrectMethodNames()
{
// Arrange
var capabilities = ScanCapabilitiesFromTestAssembly();
// Assert method names are derived correctly
var addTestRedis = capabilities.First(c => c.CapabilityId == "Aspire.Hosting.CodeGeneration.TypeScript.Tests/addTestRedis");
Assert.Equal("addTestRedis", addTestRedis.MethodName);
var withPersistence = capabilities.First(c => c.CapabilityId == "Aspire.Hosting.CodeGeneration.TypeScript.Tests/withPersistence");
Assert.Equal("withPersistence", withPersistence.MethodName);
}
[Fact]
public void GenerateDistributedApplication_WithTestTypes_CapturesParameters()
{
// Arrange
var capabilities = ScanCapabilitiesFromTestAssembly();
// Assert parameters are captured
// The builder parameter is skipped because TargetTypeId is inferred from the first parameter
// (IDistributedApplicationBuilder -> "Aspire.Hosting/Aspire.Hosting.IDistributedApplicationBuilder")
var addTestRedis = capabilities.First(c => c.CapabilityId == "Aspire.Hosting.CodeGeneration.TypeScript.Tests/addTestRedis");
Assert.Equal(2, addTestRedis.Parameters.Count);
Assert.Equal("Aspire.Hosting/Aspire.Hosting.IDistributedApplicationBuilder", addTestRedis.TargetTypeId);
Assert.Contains(addTestRedis.Parameters, p => p.Name == "name" && p.Type?.TypeId == "string");
Assert.Contains(addTestRedis.Parameters, p => p.Name == "port" && p.IsOptional);
}
[Fact]
public void Scanner_WithTestTypes_CapturesXmlDocumentation()
{
var context = CreateContextFromTestAssembly();
var addTestRedis = context.Capabilities.First(c => c.CapabilityId == "Aspire.Hosting.CodeGeneration.TypeScript.Tests/addTestRedis");
Assert.Equal("Adds a test Redis resource from ATS documentation.", addTestRedis.Description);
Assert.Equal("Adds a test Redis resource from ATS documentation.", addTestRedis.Documentation?.Summary);
Assert.Null(addTestRedis.Documentation?.Remarks);
Assert.Equal("The ATS test Redis resource builder.", addTestRedis.Documentation?.Returns);
var nameParameter = addTestRedis.Parameters.First(p => p.Name == "name");
Assert.Equal("The ATS resource name.", nameParameter.Documentation?.Summary);
var portParameter = addTestRedis.Parameters.First(p => p.Name == "port");
Assert.Null(portParameter.Documentation);
var testConfig = context.DtoTypes.First(dto => dto.Name == nameof(TestConfigDto));
Assert.Equal("Test DTO to verify [AspireDto] generates TypeScript interfaces.", testConfig.Documentation?.Summary);
Assert.Equal("The name of the test config.", testConfig.Properties.First(p => p.Name == nameof(TestConfigDto.Name)).Documentation?.Summary);
var testStatus = context.EnumTypes.First(e => e.Name == nameof(TestResourceStatus));
Assert.Equal("Test enum for type generation verification.", testStatus.Documentation?.Summary);
Assert.Equal("The resource is pending.", testStatus.ValueInfos.First(v => v.Name == nameof(TestResourceStatus.Pending)).Documentation?.Summary);
var defaultConfig = context.ExportedValues.First(value => string.Join(".", value.PathSegments) == "TestConfigs.Default");
Assert.Equal("The default test configuration.", defaultConfig.Documentation?.Summary);
}
[Fact]
public void GenerateDistributedApplication_WithTestTypes_EmitsXmlDocumentationAsJSDoc()
{
var context = CreateContextFromTestAssembly();
var files = _generator.GenerateDistributedApplication(context);
var aspireTs = files["aspire.mts"];
Assert.Contains("Adds a test Redis resource from ATS documentation.", aspireTs);
Assert.Contains("@param name The ATS resource name.", aspireTs);
Assert.Contains("@param options Additional options.", aspireTs);
Assert.Contains("@returns The ATS test Redis resource builder.", aspireTs);
Assert.DoesNotContain("The optional Redis port.", aspireTs);
Assert.DoesNotContain("Uses XML documentation instead of the attribute description when both are present.", aspireTs);
Assert.Contains("/** The name of the test config. */", aspireTs);
Assert.Contains("/** The default test configuration. */", aspireTs);
Assert.Contains("/** The resource is pending. */", aspireTs);
}
[Fact]
public void GenerateDistributedApplication_WithSuppressedSummary_DoesNotUseDescriptionFallback()
{
var context = CreateContextFromTestAssembly();
var capability = CreateDistributedApplicationBuilderCapability(
context,
methodName: "withSuppressedSummary",
description: "Description fallback should not be emitted.",
documentation: new AtsDocumentationInfo());
context = WithAdditionalCapabilities(context, capability);
var files = _generator.GenerateDistributedApplication(context);
var aspireTs = files["aspire.mts"];
Assert.Contains("withSuppressedSummary()", aspireTs);
Assert.DoesNotContain("Description fallback should not be emitted.", aspireTs);
}
[Fact]
public void GenerateDistributedApplication_WithVoidReturn_DoesNotEmitReturnsDocumentation()
{
var context = CreateContextFromTestAssembly();
var capability = CreateDistributedApplicationBuilderCapability(
context,
methodName: "withVoidReturnDocumentation",
description: null,
documentation: new AtsDocumentationInfo
{
Summary = "Runs a void capability.",
Returns = "Void return documentation should not be emitted."
});
context = WithAdditionalCapabilities(context, capability);
var files = _generator.GenerateDistributedApplication(context);
var aspireTs = files["aspire.mts"];
Assert.Contains("Runs a void capability.", aspireTs);
Assert.DoesNotContain("Void return documentation should not be emitted.", aspireTs);
}
[Fact]
public void GenerateDistributedApplication_WithAtsReference_RendersJsDocLink()
{
var context = CreateContextFromTestAssembly();
var capability = CreateDistributedApplicationBuilderCapability(
context,
methodName: "withAtsReference",
description: null,
documentation: new AtsDocumentationInfo
{
Summary = "Configures {@ats-ref type:TestRedisResource} from ATS documentation."
});
context = WithAdditionalCapabilities(context, capability);
var files = _generator.GenerateDistributedApplication(context);
var aspireTs = files["aspire.mts"];
Assert.Contains("Configures {@link TestRedisResource} from ATS documentation.", aspireTs);
Assert.DoesNotContain("{@ats-ref", aspireTs);
}
[Fact]
public void GenerateDistributedApplication_WithContextType_GeneratesPropertyCapabilities()
{
// Arrange
var capabilities = ScanCapabilitiesFromTestAssembly();
// Check for any context property capabilities (those with PropertyGetter or PropertySetter kind)
var contextCapabilities = capabilities.Where(c =>
c.CapabilityKind == AtsCapabilityKind.PropertyGetter ||
c.CapabilityKind == AtsCapabilityKind.PropertySetter).ToList();
// Assert context type property capabilities are discovered
// TestCallbackContext has [AspireContextType] - type ID is derived as {AssemblyName}/{TypeName}
// = Aspire.Hosting.CodeGeneration.TypeScript.Tests/TestCallbackContext
// with Name (string) and Value (int) properties
//
// Note: Context type scanning requires the AspireContextTypeAttribute to be resolvable
// from the assembly's metadata. If no context capabilities are found, it may be because
// the attribute type couldn't be resolved.
if (contextCapabilities.Count == 0)
{
// Skip this test if no context types were found - this could be due to
// attribute resolution issues in the metadata reader
return;
}
// Test getter capability for Name property (camelCase, no "get" prefix)
// Note: Capability IDs use namespace-based package (Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes)
// But TargetTypeId uses the new format {AssemblyName}/{FullTypeName}
var nameGetterCapability = capabilities.FirstOrDefault(c => c.CapabilityId == "Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes/TestCallbackContext.name");
Assert.NotNull(nameGetterCapability);
Assert.Equal(AtsCapabilityKind.PropertyGetter, nameGetterCapability.CapabilityKind);
Assert.Equal("TestCallbackContext.name", nameGetterCapability.QualifiedMethodName);
Assert.Equal("string", nameGetterCapability.ReturnType?.TypeId);
Assert.Equal("Aspire.Hosting.CodeGeneration.TypeScript.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes.TestCallbackContext", nameGetterCapability.TargetTypeId);
Assert.Single(nameGetterCapability.Parameters);
Assert.Equal("context", nameGetterCapability.Parameters[0].Name);
// Test setter capability for Name property (writable)
var nameSetterCapability = capabilities.FirstOrDefault(c => c.CapabilityId == "Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes/TestCallbackContext.setName");
Assert.NotNull(nameSetterCapability);
Assert.Equal(AtsCapabilityKind.PropertySetter, nameSetterCapability.CapabilityKind);
Assert.Equal("TestCallbackContext.setName", nameSetterCapability.QualifiedMethodName);
Assert.Equal("Aspire.Hosting.CodeGeneration.TypeScript.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes.TestCallbackContext", nameSetterCapability.ReturnType?.TypeId); // Returns context for fluent chaining
Assert.Equal(2, nameSetterCapability.Parameters.Count); // context + value
// Test getter capability for Value property (camelCase, no "get" prefix)
var valueGetterCapability = capabilities.FirstOrDefault(c => c.CapabilityId == "Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes/TestCallbackContext.value");
Assert.NotNull(valueGetterCapability);
Assert.Equal(AtsCapabilityKind.PropertyGetter, valueGetterCapability.CapabilityKind);
Assert.Equal("TestCallbackContext.value", valueGetterCapability.QualifiedMethodName);
Assert.Equal("number", valueGetterCapability.ReturnType?.TypeId);
// Test setter capability for Value property (writable)
var valueSetterCapability = capabilities.FirstOrDefault(c => c.CapabilityId == "Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes/TestCallbackContext.setValue");
Assert.NotNull(valueSetterCapability);
Assert.Equal(AtsCapabilityKind.PropertySetter, valueSetterCapability.CapabilityKind);
// CancellationToken - the type mapping is in Aspire.Hosting assembly.
// Since the test only loads the test assembly's type mapping, CancellationToken
// maps to "any" and is skipped as non-ATS-compatible.
// In production, when Aspire.Hosting is loaded, CancellationToken will be properly mapped.
}
[Fact]
public void Scanner_TestRedisResource_ImplementsIResource()
{
// This test verifies that TestRedisResource's interface collection includes IResource
// which is inherited through: TestRedisResource -> ContainerResource -> Resource -> IResource
var testRedisType = typeof(TestRedisResource);
// Collect all interfaces recursively (simulating what the scanner does)
var allInterfaces = new HashSet<string>();
CollectAllInterfacesRecursive(testRedisType, allInterfaces);
// Should include IResource (inherited from ContainerResource -> Resource)
Assert.Contains(allInterfaces, i => i.Contains("IResource") && !i.Contains("IResourceWith"));
// Should include IResourceWithConnectionString (directly implemented)
Assert.Contains(allInterfaces, i => i.Contains("IResourceWithConnectionString"));
}
private static void CollectAllInterfacesRecursive(Type type, HashSet<string> collected)
{
// Add directly implemented interfaces
foreach (var iface in type.GetInterfaces())
{
if (collected.Add(iface.FullName ?? iface.Name))
{
// Also collect interfaces that this interface extends
CollectAllInterfacesRecursive(iface, collected);
}
}
// Also check base type
if (type.BaseType != null && type.BaseType.FullName != "System.Object")
{
CollectAllInterfacesRecursive(type.BaseType, collected);
}
}
[Fact]
public void Scanner_WithOptionalString_TargetsIResource()
{
// This test verifies that WithOptionalString<T> where T : IResource
// correctly targets IResource using the new {AssemblyName}/{FullTypeName} format
var capabilities = ScanCapabilitiesFromTestAssembly();
// Find the withOptionalString capability
var withOptionalString = capabilities
.FirstOrDefault(c => c.CapabilityId == "Aspire.Hosting.CodeGeneration.TypeScript.Tests/withOptionalString");
Assert.NotNull(withOptionalString);
// Target should be IResource from the constraint (new format: {AssemblyName}/{FullTypeName})
Assert.Equal("Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResource", withOptionalString.TargetTypeId);
}
[Fact]
public void Scanner_WithOptionalString_ExpandsToTestRedis()
{
// This test verifies that WithOptionalString<T> where T : IResource
// has its ExpandedTargetTypeIds include TestRedisResource
var capabilities = ScanCapabilitiesFromTestAssembly();
// Find the withOptionalString capability
var withOptionalString = capabilities
.FirstOrDefault(c => c.CapabilityId == "Aspire.Hosting.CodeGeneration.TypeScript.Tests/withOptionalString");
Assert.NotNull(withOptionalString);
// Expanded targets should include TestRedisResource (new format: {AssemblyName}/{FullTypeName})
Assert.NotNull(withOptionalString.ExpandedTargetTypes);
var testRedisTarget = withOptionalString.ExpandedTargetTypes.FirstOrDefault(t =>
t.TypeId == "Aspire.Hosting.CodeGeneration.TypeScript.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes.TestRedisResource");
Assert.NotNull(testRedisTarget);
// Verify that concrete types in ExpandedTargetTypes have IsInterface = false
Assert.False(testRedisTarget.IsInterface, "TestRedisResource is a concrete type, not an interface");
}
[Fact]
public void Scanner_BaseTypeChain_CollectsInterfacesAcrossAssemblies()
{
// Debug test to understand the base type chain using runtime reflection
var testRedisType = typeof(TestRedisResource);
// Collect base type chain
var baseTypes = new List<string>();
var currentType = testRedisType.BaseType;
while (currentType != null && currentType.FullName != "System.Object")
{
baseTypes.Add(currentType.FullName ?? currentType.Name);
currentType = currentType.BaseType;
}
// Should have ContainerResource and Resource in the chain
Assert.Contains(baseTypes, t => t.Contains("ContainerResource"));
Assert.Contains(baseTypes, t => t.Contains("Resource") && !t.Contains("Container"));
}
[Fact]
public async Task Scanner_AddTestRedis_HasCorrectTypeMetadata()
{
// Verify the entire capability object for addTestRedis
var capabilities = ScanCapabilitiesFromTestAssembly();
var addTestRedis = capabilities.FirstOrDefault(c => c.CapabilityId == "Aspire.Hosting.CodeGeneration.TypeScript.Tests/addTestRedis");
Assert.NotNull(addTestRedis);
await Verify(addTestRedis).UseFileName("AddTestRedisCapability");
}
[Fact]
public void Scanner_ReturnsBuilder_TrueForResourceBuilderReturnTypes()
{
// Regression test: Verify that ReturnsBuilder is correctly set to true for methods
// that return IResourceBuilder<T>, even during code generation scanning where
// typeResolver is null. Previously, the scanner incorrectly required typeResolver
// to be non-null to detect resource builder return types.
var capabilities = ScanCapabilitiesFromTestAssembly();
// addTestRedis returns IResourceBuilder<TestRedisResource> - should have ReturnsBuilder = true
var addTestRedis = capabilities.FirstOrDefault(c => c.CapabilityId == "Aspire.Hosting.CodeGeneration.TypeScript.Tests/addTestRedis");
Assert.NotNull(addTestRedis);
Assert.True(addTestRedis.ReturnsBuilder,
"addTestRedis returns IResourceBuilder<T> but ReturnsBuilder is false - thenable wrapper won't be generated");
// withPersistence also returns IResourceBuilder<T>
var withPersistence = capabilities.FirstOrDefault(c => c.CapabilityId == "Aspire.Hosting.CodeGeneration.TypeScript.Tests/withPersistence");
Assert.NotNull(withPersistence);
Assert.True(withPersistence.ReturnsBuilder,
"withPersistence returns IResourceBuilder<T> but ReturnsBuilder is false - thenable wrapper won't be generated");
// withRedisSpecific also returns IResourceBuilder<T>
var withRedisSpecific = capabilities.FirstOrDefault(c => c.CapabilityId == "Aspire.Hosting.CodeGeneration.TypeScript.Tests/withRedisSpecific");
Assert.NotNull(withRedisSpecific);
Assert.True(withRedisSpecific.ReturnsBuilder,
"withRedisSpecific returns IResourceBuilder<T> but ReturnsBuilder is false - thenable wrapper won't be generated");
}
[Fact]
public void FactoryMethod_ReturnsChildResourceType_NotParentType()
{
// Regression test: Factory methods on a builder (e.g., AddDatabase on SqlServerServerResource)
// must return the child resource type, not the parent/receiver type.
// Previously, the codegen always used the builder's own type for the return type,
// causing addDatabase() to return SqlServerServerResourcePromise instead of
// SqlServerDatabaseResourcePromise.
var atsContext = CreateContextFromTestAssembly();
var files = _generator.GenerateDistributedApplication(atsContext);
var aspireTs = files["aspire.mts"];
// addTestChildDatabase is a factory method on TestRedisResource that returns TestDatabaseResource.
// The generated internal method must return TestDatabaseResource, not TestRedisResource.
Assert.Contains("_addTestChildDatabaseInternal", aspireTs);
Assert.Contains("Promise<TestDatabaseResource>", aspireTs);
// The public fluent method must return TestDatabaseResourcePromise, not TestRedisResourcePromise.
Assert.Matches(@"addTestChildDatabase\([^)]*\):\s*TestDatabaseResourcePromise", aspireTs);
// Verify the thenable class also uses the child type's promise class.
// In TestRedisResourcePromise, addTestChildDatabase should return TestDatabaseResourcePromise.
Assert.Contains("new TestDatabaseResourcePromiseImpl(this._promise.then(obj => obj.addTestChildDatabase(", aspireTs);
}
[Fact]
public async Task Scanner_WithPersistence_HasCorrectExpandedTargets()
{
// Verify the entire capability object for withPersistence
var capabilities = ScanCapabilitiesFromTestAssembly();
var withPersistence = capabilities.FirstOrDefault(c => c.CapabilityId == "Aspire.Hosting.CodeGeneration.TypeScript.Tests/withPersistence");
Assert.NotNull(withPersistence);
await Verify(withPersistence).UseFileName("WithPersistenceCapability");
}
[Fact]
public async Task Scanner_WithOptionalString_HasCorrectExpandedTargets()
{
// Verify withOptionalString (targets IResource, should expand to TestRedisResource)
var capabilities = ScanCapabilitiesFromTestAssembly();
var withOptionalString = capabilities.FirstOrDefault(c => c.CapabilityId == "Aspire.Hosting.CodeGeneration.TypeScript.Tests/withOptionalString");
Assert.NotNull(withOptionalString);
await Verify(withOptionalString).UseFileName("WithOptionalStringCapability");
}
[Fact]
public async Task Scanner_HostingAssembly_AddContainerCapability()
{
// Verify the addContainer capability from the real Aspire.Hosting assembly
var capabilities = ScanCapabilitiesFromHostingAssembly();
var addContainer = capabilities.FirstOrDefault(c => c.CapabilityId == "Aspire.Hosting/addContainer");
Assert.NotNull(addContainer);
await Verify(addContainer).UseFileName("HostingAddContainerCapability");
}
[Fact]
public void Scanner_BrowsersAssembly_WithBrowserLogsCapability()
{
var capabilities = ScanCapabilitiesFromBrowsersAssembly();
var withBrowserLogs = capabilities.FirstOrDefault(c => c.CapabilityId == "Aspire.Hosting.Browsers/withBrowserLogs");
Assert.NotNull(withBrowserLogs);
Assert.Equal("withBrowserLogs", withBrowserLogs.MethodName);
Assert.Equal("Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithEndpoints", withBrowserLogs.TargetTypeId);
Assert.Contains(withBrowserLogs.Parameters, p => p.Name == "browser" && p.Type?.TypeId == "string" && p.IsOptional);
Assert.Contains(withBrowserLogs.Parameters, p => p.Name == "profile" && p.Type?.TypeId == "string" && p.IsOptional);
Assert.Contains(withBrowserLogs.Parameters, p => p.Name == "userDataMode" && p.IsOptional);
Assert.True(withBrowserLogs.ReturnsBuilder);
}
[Fact]
public async Task Scanner_HostingAssembly_ContainerResourceCapabilities()
{
// Verify all capabilities that target ContainerResource from Aspire.Hosting
var capabilities = ScanCapabilitiesFromHostingAssembly();
// Find all capabilities that target ContainerResource
var containerCapabilities = capabilities
.Where(c => c.TargetTypeId?.Contains("ContainerResource") == true ||
c.ExpandedTargetTypes.Any(t => t.TypeId.Contains("ContainerResource")))
.Select(c => new
{
c.CapabilityId,
c.MethodName,
TargetType = c.TargetType != null ? new { c.TargetType.TypeId, c.TargetType.IsInterface } : null,
ExpandedTargetTypes = c.ExpandedTargetTypes
.Where(t => t.TypeId.Contains("ContainerResource"))
.Select(t => new { t.TypeId, t.IsInterface })
})
.OrderBy(c => c.CapabilityId)
.ToList();
await Verify(containerCapabilities).UseFileName("HostingContainerResourceCapabilities");
}
[Fact]
public void RuntimeType_ContainerResource_IsNotInterface()
{
// Verify that ContainerResource.IsInterface returns false using runtime reflection
var containerResourceType = typeof(ContainerResource);
Assert.NotNull(containerResourceType);
Assert.False(containerResourceType.IsInterface, "ContainerResource should NOT be an interface");
}
[Fact]
public void Scanner_ContainerResource_DirectTargetingHasCorrectIsInterface()
{
// Verify that capabilities directly targeting ContainerResource have IsInterface = false
var capabilities = ScanCapabilitiesFromHostingAssembly();
// Find capabilities that directly target ContainerResource (not via interface expansion)
var directContainerCapabilities = capabilities
.Where(c => c.TargetTypeId == "Aspire.Hosting/Aspire.Hosting.ApplicationModel.ContainerResource")
.ToList();
Assert.NotEmpty(directContainerCapabilities);
foreach (var cap in directContainerCapabilities)
{
// Both TargetType and ExpandedTargetTypes should have IsInterface = false for ContainerResource
Assert.NotNull(cap.TargetType);
Assert.False(cap.TargetType.IsInterface,
$"Capability '{cap.CapabilityId}' directly targets ContainerResource but TargetType.IsInterface is true");
foreach (var expandedType in cap.ExpandedTargetTypes)
{
if (expandedType.TypeId.Contains("ContainerResource"))
{
Assert.False(expandedType.IsInterface,
$"Capability '{cap.CapabilityId}' ExpandedTargetType '{expandedType.TypeId}' has IsInterface = true");
}
}
}
}
[Fact]
public void Scanner_GenericConstraintWithClassType_CorrectlyIdentifiesAsNotInterface()
{
// This test verifies that when a method has a generic constraint like:
// IResourceBuilder<T> where T : ContainerResource
// The scanner correctly identifies ContainerResource as NOT an interface.
//
// Previously, the scanner hardcoded IsInterface = true for all generic constraints,
// which was wrong when the constraint is a class (like ContainerResource).
var capabilities = ScanCapabilitiesFromHostingAssembly();
// Find withBindMount - it has signature: IResourceBuilder<T> where T : ContainerResource
var withBindMount = capabilities.FirstOrDefault(c => c.CapabilityId == "Aspire.Hosting/withBindMount");
Assert.NotNull(withBindMount);
// The constraint is ContainerResource (a class), so IsInterface should be false
Assert.NotNull(withBindMount.TargetType);
Assert.Equal("Aspire.Hosting/Aspire.Hosting.ApplicationModel.ContainerResource", withBindMount.TargetType.TypeId);
Assert.False(withBindMount.TargetType.IsInterface,
"ContainerResource is a class, not an interface - IsInterface should be false");
// Compare with an interface-constrained capability like withEnvironment
var withEnvironment = capabilities.FirstOrDefault(c => c.CapabilityId == "Aspire.Hosting/withEnvironment");
Assert.NotNull(withEnvironment);
Assert.NotNull(withEnvironment.TargetType);
Assert.True(withEnvironment.TargetType.IsInterface,
"IResourceWithEnvironment is an interface - IsInterface should be true");
}
// ===== Polymorphism Pattern Tests =====
[Fact]
public void Pattern2_InterfaceTypeDirectly_IsDiscoveredAndExpanded()
{
// Pattern 2: Interface type directly as target (not via generic constraint)
// Tests: IResourceBuilder<IResourceWithConnectionString> WithConnectionStringDirect(...)
// The interface target should be expanded to all types implementing IResourceWithConnectionString.
var capabilities = ScanCapabilitiesFromTestAssembly();
var withConnectionStringDirect = capabilities
.FirstOrDefault(c => c.CapabilityId == "Aspire.Hosting.CodeGeneration.TypeScript.Tests/withConnectionStringDirect");
Assert.NotNull(withConnectionStringDirect);
// Target should be the interface
Assert.NotNull(withConnectionStringDirect.TargetType);
Assert.Contains("IResourceWithConnectionString", withConnectionStringDirect.TargetType.TypeId);
Assert.True(withConnectionStringDirect.TargetType.IsInterface);
// Should be expanded to concrete types implementing IResourceWithConnectionString
Assert.NotEmpty(withConnectionStringDirect.ExpandedTargetTypes);
// TestRedisResource implements IResourceWithConnectionString
var testRedisExpanded = withConnectionStringDirect.ExpandedTargetTypes
.FirstOrDefault(t => t.TypeId.Contains("TestRedisResource"));
Assert.NotNull(testRedisExpanded);
Assert.False(testRedisExpanded.IsInterface, "Expanded concrete type should have IsInterface = false");
}
[Fact]
public void Pattern3_ConcreteTypeWithInheritance_ExpandsToDerivedTypes()
{
// Pattern 3: Concrete type with inheritance
// Tests: IResourceBuilder<TestRedisResource> WithRedisSpecific(...)
// Should expand to TestRedisResource and any derived types.
var capabilities = ScanCapabilitiesFromTestAssembly();
var withRedisSpecific = capabilities
.FirstOrDefault(c => c.CapabilityId == "Aspire.Hosting.CodeGeneration.TypeScript.Tests/withRedisSpecific");
Assert.NotNull(withRedisSpecific);
// Target should be the concrete TestRedisResource type
Assert.NotNull(withRedisSpecific.TargetType);
Assert.Contains("TestRedisResource", withRedisSpecific.TargetType.TypeId);
Assert.False(withRedisSpecific.TargetType.IsInterface, "TestRedisResource is a concrete type");
// Should be expanded (at minimum to itself)
Assert.NotEmpty(withRedisSpecific.ExpandedTargetTypes);
// TestRedisResource should be in expanded targets
var testRedisExpanded = withRedisSpecific.ExpandedTargetTypes
.FirstOrDefault(t => t.TypeId.Contains("TestRedisResource"));
Assert.NotNull(testRedisExpanded);
}
[Fact]
public void Pattern3_ConcreteTypeFromHosting_ExpandsToDerivedTypes()
{
// Pattern 3 for Hosting assembly: ContainerResource methods should expand to derived types
// Tests: withVolume, withBindMount target ContainerResource and should expand to
// all types that inherit from ContainerResource.
var capabilities = ScanCapabilitiesFromHostingAssembly();
// Find withBindMount which targets ContainerResource
var withBindMount = capabilities.FirstOrDefault(c => c.CapabilityId == "Aspire.Hosting/withBindMount");
Assert.NotNull(withBindMount);
// Target is ContainerResource (concrete class)
Assert.NotNull(withBindMount.TargetType);
Assert.Contains("ContainerResource", withBindMount.TargetType.TypeId);
Assert.False(withBindMount.TargetType.IsInterface);
// Should be expanded to ContainerResource AND derived types
Assert.NotEmpty(withBindMount.ExpandedTargetTypes);
// ContainerResource itself should be in expanded targets
var containerExpanded = withBindMount.ExpandedTargetTypes
.FirstOrDefault(t => t.TypeId.Contains("ContainerResource") && !t.TypeId.Contains("IContainer"));
Assert.NotNull(containerExpanded);
}
[Fact]
public void Pattern4_InterfaceParameterType_HasCorrectTypeRef()
{
// Pattern 4: Interface type as parameter (not target)
// Tests: WithDependency<T>(..., IResourceBuilder<IResourceWithConnectionString> dependency)
// The dependency parameter should have an interface type ref that can be used for union type generation.
var capabilities = ScanCapabilitiesFromTestAssembly();
var withDependency = capabilities
.FirstOrDefault(c => c.CapabilityId == "Aspire.Hosting.CodeGeneration.TypeScript.Tests/withDependency");
Assert.NotNull(withDependency);
// Find the dependency parameter
var dependencyParam = withDependency.Parameters.FirstOrDefault(p => p.Name == "dependency");
Assert.NotNull(dependencyParam);
// Parameter type should be a handle type for IResourceWithConnectionString
Assert.NotNull(dependencyParam.Type);
Assert.Equal(AtsTypeCategory.Handle, dependencyParam.Type.Category);
Assert.True(dependencyParam.Type.IsInterface, "IResourceWithConnectionString is an interface");
}
[Fact]
public void Pattern4_InterfaceParameterType_GeneratesUnionType()
{
// Interface-constrained resource parameters should expand to the concrete
// wrapper interfaces/classes that satisfy the interface contract.
var atsContext = CreateContextFromTestAssembly();
// Generate the TypeScript output
var files = _generator.GenerateDistributedApplication(atsContext);
var aspireTs = files["aspire.mts"];
Assert.Contains("withDependency(dependency: Awaitable<ResourceWithConnectionString | TestRedisResource>)", aspireTs);
Assert.DoesNotContain("withDependency(dependency: HandleReference)", aspireTs);
}
[Fact]
public void AspireUnion_InterfaceHandleInput_GeneratesExpandedUnion()
{
var atsContext = CreateContextFromTestAssembly();
var files = _generator.GenerateDistributedApplication(atsContext);
var aspireTs = files["aspire.mts"];
Assert.Contains("withUnionDependency(dependency: string | ResourceWithConnectionString | TestRedisResource | Awaitable<ResourceWithConnectionString | TestRedisResource>)", aspireTs);
}
[Fact]
public void MapInputUnionTypeToTypeScript_ThrowsOnEmptyUnion()
{
var projector = new TypeScriptApiProjector(CreateContextFromTestAssembly());
var typeRef = new AtsTypeRef
{
TypeId = "test/EmptyUnion",
Category = AtsTypeCategory.Union,
UnionTypes = [],
};
var ex = Assert.Throws<InvalidOperationException>(() => projector.MapInputUnionTypeToTypeScript(typeRef));
Assert.Equal("Union input types must define at least one member type.", ex.Message);
}
[Fact]
public async Task Scanner_BaseTypeHierarchy_IsCollected()
{
// Verify that AtsTypeInfo includes base type hierarchy for inheritance expansion.
var capabilities = ScanCapabilitiesFromTestAssembly();
// We need to verify the type info has base type hierarchy
// For now, we'll verify through expanded targets behavior -
// if inheritance expansion works, base types are being collected.
var withRedisSpecific = capabilities
.FirstOrDefault(c => c.CapabilityId == "Aspire.Hosting.CodeGeneration.TypeScript.Tests/withRedisSpecific");
Assert.NotNull(withRedisSpecific);
// Snapshot the capability to verify structure
await Verify(withRedisSpecific).UseFileName("WithRedisSpecificCapability");
}
[Fact]
public void BugFix_SyntheticTypeInfo_CorrectlyIdentifiesInterfaceTypes()
{
// Bug: Synthetic type info created for discovered types had IsInterface hardcoded to false.
// This caused interface types like IResourceWithConnectionString to be incorrectly processed,
// preventing proper interface-to-concrete-type expansion.
//
// Fix: Set IsInterface = resourceType.IsInterface instead of hardcoded false.
//
// This test verifies that when a method targets an interface directly (Pattern 2),
// the capability correctly expands to concrete types implementing that interface.
var capabilities = ScanCapabilitiesFromTestAssembly();
// withConnectionStringDirect targets IResourceWithConnectionString (an interface)
var withConnectionStringDirect = capabilities
.FirstOrDefault(c => c.CapabilityId == "Aspire.Hosting.CodeGeneration.TypeScript.Tests/withConnectionStringDirect");
Assert.NotNull(withConnectionStringDirect);
// Target type should be correctly identified as an interface
Assert.NotNull(withConnectionStringDirect.TargetType);
Assert.True(withConnectionStringDirect.TargetType.IsInterface,
"IResourceWithConnectionString should be identified as an interface");
// Should expand to concrete types, NOT remain as just the interface
Assert.NotEmpty(withConnectionStringDirect.ExpandedTargetTypes);
// All expanded types should be concrete (IsInterface = false)
foreach (var expandedType in withConnectionStringDirect.ExpandedTargetTypes)
{
Assert.False(expandedType.IsInterface,
$"Expanded type '{expandedType.TypeId}' should be a concrete type, not an interface");
}
}
[Fact]
public void BugFix_InterfaceExpansion_WorksAcrossAssemblies()
{
// Bug: withReference targeting IResourceWithEnvironment was not being expanded
// because the interface type was incorrectly marked as IsInterface=false.
//
// This test verifies that capabilities targeting Aspire.Hosting interfaces
// (like IResourceWithEnvironment) correctly expand when concrete types
// from other assemblies (like TestRedisResource) implement those interfaces.
var capabilities = ScanCapabilitiesFromTestAssembly();
// testWithEnvironmentCallback targets IResourceWithEnvironment (generic constraint)
// and TestRedisResource implements IResourceWithEnvironment (via ContainerResource)
var testWithEnvironmentCallback = capabilities
.FirstOrDefault(c => c.CapabilityId == "Aspire.Hosting.CodeGeneration.TypeScript.Tests/testWithEnvironmentCallback");
Assert.NotNull(testWithEnvironmentCallback);
// Target type should be IResourceWithEnvironment (an interface)
Assert.NotNull(testWithEnvironmentCallback.TargetType);
Assert.Contains("IResourceWithEnvironment", testWithEnvironmentCallback.TargetType.TypeId);
Assert.True(testWithEnvironmentCallback.TargetType.IsInterface,
"IResourceWithEnvironment should be identified as an interface");
// Should expand to TestRedisResource (which implements IResourceWithEnvironment via ContainerResource)
Assert.NotEmpty(testWithEnvironmentCallback.ExpandedTargetTypes);
// TestRedisResource should be in expanded targets
var testRedisExpanded = testWithEnvironmentCallback.ExpandedTargetTypes
.FirstOrDefault(t => t.TypeId.Contains("TestRedisResource"));
Assert.NotNull(testRedisExpanded);
Assert.False(testRedisExpanded.IsInterface, "TestRedisResource is a concrete type");
}
[Fact]
public void BugFix_TargetParameterName_IsPopulatedFromMethodSignature()
{
// Verify that TargetParameterName is populated from the actual method signature
// so the code generator uses the correct parameter name when invoking capabilities.
var capabilities = ScanCapabilitiesFromHostingAssembly();
// Find withReference - now on the original ResourceBuilderExtensions.WithReference
// which uses "builder" as the first parameter name
var withReference = capabilities
.FirstOrDefault(c => c.CapabilityId == "Aspire.Hosting/withReference");
Assert.NotNull(withReference);
Assert.Equal("builder", withReference.TargetParameterName);
// Verify other capabilities have the expected parameter names
var addContainer = capabilities
.FirstOrDefault(c => c.CapabilityId == "Aspire.Hosting/addContainer");
Assert.NotNull(addContainer);
Assert.Equal("builder", addContainer.TargetParameterName);
// withEnvironment uses "builder" as the first parameter
var withEnvironment = capabilities
.FirstOrDefault(c => c.CapabilityId == "Aspire.Hosting/withEnvironment");
Assert.NotNull(withEnvironment);
Assert.Equal("builder", withEnvironment.TargetParameterName);
}
[Fact]
public void Scanner_HostingAssembly_UsesUnifiedWithReferenceCapability()
{
var capabilities = ScanCapabilitiesFromHostingAssembly();
var withReference = Assert.Single(capabilities, c => c.CapabilityId == "Aspire.Hosting/withReference");
Assert.Contains(withReference.Parameters, p => p.Name == "name" && p.IsOptional);
Assert.DoesNotContain(capabilities, c => c.CapabilityId == "Aspire.Hosting/withServiceReference");
Assert.DoesNotContain(capabilities, c => c.CapabilityId == "Aspire.Hosting/withServiceReferenceNamed");
}
[Fact]
public void BugFix_TargetParameterName_WithVolumeUsesResource()
{
// Verify that withVolume has TargetParameterName = "resource" (from CoreExports.cs)
// This was a bug where the generated TypeScript used "builder" instead of "resource"
var capabilities = ScanCapabilitiesFromHostingAssembly();
// Find withVolume - this was fixed by moving to CoreExports.WithVolume with "resource" param
var withVolume = capabilities
.FirstOrDefault(c => c.CapabilityId == "Aspire.Hosting/withVolume");
Assert.NotNull(withVolume);
Assert.Equal("resource", withVolume.TargetParameterName);
// Verify correct parameter order: target comes first (required), then name (optional)
Assert.Equal("target", withVolume.Parameters[0].Name);
Assert.Equal("name", withVolume.Parameters[1].Name);
// Note: withBindMount still uses "builder" - it hasn't been moved to CoreExports yet
var withBindMount = capabilities
.FirstOrDefault(c => c.CapabilityId == "Aspire.Hosting/withBindMount");
Assert.NotNull(withBindMount);
Assert.Equal("builder", withBindMount.TargetParameterName); // TODO: Should be moved to CoreExports
// withCommand uses "builder" as expected (it's on ResourceBuilderExtensions)
var withCommand = capabilities
.FirstOrDefault(c => c.CapabilityId == "Aspire.Hosting/withCommand");
Assert.NotNull(withCommand);
Assert.Equal("builder", withCommand.TargetParameterName);
}
// ===== 2-Pass Scanning / Cross-Assembly Expansion Tests =====
[Fact]
public void TwoPassScanning_DeduplicatesCapabilities()
{
// Verify that when the same capability appears in multiple assemblies (e.g., via shared export),
// ScanAssemblies deduplicates by CapabilityId.
var capabilities = ScanCapabilitiesFromBothAssemblies();
// Each capability ID should appear only once
var duplicates = capabilities
.GroupBy(c => c.CapabilityId)
.Where(g => g.Count() > 1)
.Select(g => g.Key)
.ToList();
Assert.Empty(duplicates);
}
[Fact]
public void TwoPassScanning_MergesHandleTypesFromAllAssemblies()
{
// Verify that ScanAssemblies collects handle types from all assemblies
var result = CreateContextFromBothAssemblies();
// Should have types from Aspire.Hosting (ContainerResource, etc.)
var containerResourceType = result.HandleTypes
.FirstOrDefault(t => t.AtsTypeId.Contains("ContainerResource") && !t.AtsTypeId.Contains("IContainer"));
Assert.NotNull(containerResourceType);
// Should have types from test assembly (TestRedisResource)
var testRedisType = result.HandleTypes
.FirstOrDefault(t => t.AtsTypeId.Contains("TestRedisResource"));
Assert.NotNull(testRedisType);
// TestRedisResource should have IResourceWithEnvironment in its interfaces
// (inherited via ContainerResource)
var hasEnvironmentInterface = testRedisType.ImplementedInterfaces
.Any(i => i.TypeId.Contains("IResourceWithEnvironment"));
Assert.True(hasEnvironmentInterface,
"TestRedisResource should implement IResourceWithEnvironment via ContainerResource");
}
[Fact]
public void GenerateDistributedApplication_EmitsPromiseWrapperForBareMarkerResourceBuilder()
{
// Durable regression test for https://github.com/microsoft/aspire/issues/19507, using the
// ITestMarkerResource fixture rather than an in-the-box type that happens to have zero
// capabilities today: an [AspireExport] method returning IResourceBuilder<T> for a bare
// marker interface emits "{ClassName}Promise" as its return type (the reference site),
// while the wrapper declaration used to be skipped because the builder had no capabilities
// of its own - a dangling "Cannot find name '...Promise'" in the generated SDK.
// Also reference the concrete implementation so CreateBuilderModels deduplicates the
// interface and concrete builders to the same generated TestMarkerResource class. Wrapper
// registration must still honor the interface return type from AddTestMarker.
var atsContext = WithAdditionalCapabilities(
CreateContextFromTestAssembly(),
CreateVoidEntryPointCapability(
"inspectTestMarkerResource",
new AtsParameterInfo
{
Name = "resource",
Type = CreateResourceTypeRef<TestMarkerResource>()
}));
var files = _generator.GenerateDistributedApplication(atsContext);
var aspireTs = files["aspire.mts"];
// The reference site: the exported entry point returns the Promise wrapper for chaining.
Assert.Contains("): TestMarkerResourcePromise {", aspireTs);
// The declarations that reference requires.
Assert.Contains(
"export interface TestMarkerResourcePromise extends PromiseLike<TestMarkerResource>",
aspireTs);
Assert.Contains(
"class TestMarkerResourcePromiseImpl implements TestMarkerResourcePromise",
aspireTs);
}
[Fact]
public void GenerateDistributedApplication_DoesNotEmitUnusedPromiseWrappersForParameterOnlyResources()
{
// ITestPromiseCollisionResource and ITestPromiseCollisionResourcePromise are intentionally
// referenced only as capability parameters. The latter's ordinary generated name is the
// former's Promise wrapper name, so emitting an unused wrapper produces duplicate TypeScript
// declarations for both the interface and implementation class.
var atsContext = CreateContextFromTestAssembly();
var files = _generator.GenerateDistributedApplication(atsContext);
var aspireTs = files["aspire.mts"];
Assert.Equal(1, CountOccurrences(aspireTs, "export interface TestPromiseCollisionResource "));
Assert.Equal(1, CountOccurrences(aspireTs, "class TestPromiseCollisionResourceImpl "));
Assert.Equal(1, CountOccurrences(aspireTs, "export interface TestPromiseCollisionResourcePromise "));
Assert.Equal(1, CountOccurrences(aspireTs, "class TestPromiseCollisionResourcePromiseImpl "));
}
[Fact]
public void GenerateDistributedApplication_DoesNotEmitUnusedPromiseWrappersForMutablePropertyResources()
{
// ITestMutablePromiseCollisionResource has only get/set properties. Its property setter's
// fluent metadata reports the owning resource as its return type, but the generated setter
// returns Promise<void> and must not reserve a Promise wrapper name that collides with the
// parameter-only TestMutablePromiseCollisionResourcePromise.
var atsContext = CreateContextFromTestAssembly();
var files = _generator.GenerateDistributedApplication(atsContext);
var aspireTs = files["aspire.mts"];
Assert.Equal(1, CountOccurrences(aspireTs, "export interface TestMutablePromiseCollisionResource "));
Assert.Equal(1, CountOccurrences(aspireTs, "class TestMutablePromiseCollisionResourceImpl "));
Assert.Equal(1, CountOccurrences(aspireTs, "export interface TestMutablePromiseCollisionResourcePromise "));
Assert.Equal(1, CountOccurrences(aspireTs, "class TestMutablePromiseCollisionResourcePromiseImpl "));
}
[Fact]
public void GenerateDistributedApplication_EmitsPromiseWrapperForReturnedInterfaceAlias()
{
// The concrete TestVaultResource is parameter-only, while the directly returned
// ITestVaultResource derives the same generated class name. Builder deduplication
// retains the concrete TypeId, but both TypeIds must resolve to its Promise wrapper.
var scannedContext = CreateContextFromTestAssembly();
var fixtureCapabilities = scannedContext.Capabilities
.Where(capability => capability.CapabilityId is
"Aspire.Hosting.CodeGeneration.TypeScript.Tests/addTestVault" or
"Aspire.Hosting.CodeGeneration.TypeScript.Tests/withConcreteVaultResource")
.ToList();
var atsContext = new AtsContext
{
Capabilities = fixtureCapabilities,
HandleTypes = scannedContext.HandleTypes,
DtoTypes = scannedContext.DtoTypes,
EnumTypes = scannedContext.EnumTypes,
ExportedValues = scannedContext.ExportedValues,
Diagnostics = scannedContext.Diagnostics
};
var files = _generator.GenerateDistributedApplication(atsContext);
var aspireTs = files["aspire.mts"];
Assert.Contains("): TestVaultResourcePromise {", aspireTs);
Assert.Equal(1, CountOccurrences(
aspireTs,
"export interface TestVaultResourcePromise extends PromiseLike<TestVaultResource>"));
Assert.Equal(1, CountOccurrences(
aspireTs,
"class TestVaultResourcePromiseImpl implements TestVaultResourcePromise"));
var returnedAliasTypeId = fixtureCapabilities
.Single(capability => capability.CapabilityId == "Aspire.Hosting.CodeGeneration.TypeScript.Tests/addTestVault")
.ReturnType!.TypeId;
// wrapIfHandle recursively uses this registry for callback arrays and plain objects, so
// the returned alias TypeId must construct the canonical wrapper for nested handles too.
Assert.Contains(
$"registerHandleWrapper('{returnedAliasTypeId}', (handle, client) => new TestVaultResourceImpl(handle as TestVaultResourceHandle, client));",
aspireTs);
Assert.Contains(
"""
async _addTestVaultInternal(name: string): Promise<TestVaultResource> {
const rpcArgs: Record<string, unknown> = { builder: this._handle, name };
const result = await this._client.invokeCapability<TestVaultResourceHandle>(
'Aspire.Hosting.CodeGeneration.TypeScript.Tests/addTestVault',
rpcArgs
);
return new TestVaultResourceImpl(result, this._client);
}
""",
aspireTs);
Assert.Equal(0, CountOccurrences(
aspireTs,
"invokeCapability<ITestVaultResourceHandle>"));
}
[Fact]
public void GenerateDistributedApplication_RejectsUnrelatedResourceTypesWithSameGeneratedName()
{
var scannedContext = CreateContextFromTestAssembly();
var addTestRedis = scannedContext.Capabilities
.Single(capability => capability.CapabilityId == "Aspire.Hosting.CodeGeneration.TypeScript.Tests/addTestRedis");
var capabilities = scannedContext.Capabilities
.Concat(
[
CreateSameNameResourceCapability<TestTypes.NameCollisionOne.SameNameResource>("addFirstSameNameResource", addTestRedis),
CreateSameNameResourceCapability<TestTypes.NameCollisionTwo.SameNameResource>("addSecondSameNameResource", addTestRedis)
])
.ToList();
var atsContext = new AtsContext
{
Capabilities = capabilities,
HandleTypes = scannedContext.HandleTypes,
DtoTypes = scannedContext.DtoTypes,
EnumTypes = scannedContext.EnumTypes,
ExportedValues = scannedContext.ExportedValues,
Diagnostics = scannedContext.Diagnostics
};
var exception = Assert.Throws<InvalidOperationException>(
() => _generator.GenerateDistributedApplication(atsContext));
Assert.Contains("SameNameResource", exception.Message);
Assert.Contains(typeof(TestTypes.NameCollisionOne.SameNameResource).FullName!, exception.Message);
Assert.Contains(typeof(TestTypes.NameCollisionTwo.SameNameResource).FullName!, exception.Message);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void GenerateDistributedApplication_EveryReferencedPromiseWrapperIsDeclared(bool includeHostingAssembly)
{
// The invariant behind https://github.com/microsoft/aspire/issues/19507, rather than one
// instance of it. "{ClassName}Promise" / "{ClassName}PromiseImpl" names are emitted from
// several independent reference sites (GenerateBuilderMethod's fluent return, the thenable
// forwarding methods, GenerateEntryPointFunction), while the declarations come from
// GenerateBuilderPromiseInterface / GenerateThenableClass / GenerateTypeClassInterface.
// Any guard that skips a declaration while a reference site still emits the name yields
// "TS2552: Cannot find name" in .aspire/modules/aspire.mts, which blocks "aspire run"
// entirely. Asserting that every referenced wrapper is declared catches that whole class of
// bug - including future reintroductions on paths this file has no explicit test for.
//
// This also guards the type-class side, where GenerateTypeClassInterface and
// GenerateTypeClass keep an analogous "no methods and no getter-only properties" guard.
// Type classes are registered via HasChainableMethods, which evaluates the same predicate,
// but the predicate remains duplicated across the three type-class sites and could drift.
// Resource builder emitters instead use the registration set directly. Both type-class
// branches are exercised by the scanned fixtures: TestResourceContext has methods (declared
// and referenced), while TestEnvironmentContext has only get/set properties (neither
// declared nor referenced).
var atsContext = includeHostingAssembly ? CreateContextFromBothAssemblies() : CreateContextFromTestAssembly();
var files = _generator.GenerateDistributedApplication(atsContext);
var aspireTs = files["aspire.mts"];
// Declarations visible to aspire.mts: its own, plus the hand-written wrappers in the
// pass-through modules it builds on (e.g. InteractionInputCollectionPromise in base.mts).
var declarations = new List<string>();
foreach (var source in new[] { aspireTs, EmbeddedResources.Read("base.mts"), EmbeddedResources.Read("transport.mts") })
{
foreach (Match match in s_promiseDeclarationPattern.Matches(StripComments(source)))
{
declarations.Add(match.Groups[1].Value);
}
}
var duplicateDeclarations = declarations
.GroupBy(name => name, StringComparer.Ordinal)
.Where(group => group.Count() > 1)
.Select(group => $"{group.Key} ({group.Count()})")
.OrderBy(name => name, StringComparer.Ordinal)
.ToList();
Assert.True(
duplicateDeclarations.Count == 0,
$"Generated modules contain duplicate Promise wrapper declaration(s): {string.Join(", ", duplicateDeclarations)}");
var declared = declarations.ToHashSet(StringComparer.Ordinal);
// Scan references with comments and string literals stripped so documentation and serialized
// ATS type IDs cannot be mistaken for TypeScript references.
var referenced = new HashSet<string>(StringComparer.Ordinal);
foreach (Match match in s_promiseReferencePattern.Matches(StripCommentsAndStringLiterals(aspireTs)))
{
referenced.Add(match.Value);
}
// Guard against a vacuous pass if either pattern stops matching the generated shape.
Assert.NotEmpty(declared);
Assert.NotEmpty(referenced);
var dangling = referenced.Except(declared).OrderBy(name => name, StringComparer.Ordinal).ToList();
Assert.True(
dangling.Count == 0,
$"Generated aspire.mts references Promise wrapper type(s) that are never declared: {string.Join(", ", dangling)}");
}
// Declarations: "export interface FooPromise extends ...", "class FooPromiseImpl implements ...".
private static readonly Regex s_promiseDeclarationPattern =
new(@"\b(?:interface|class|type)\s+(\w*Promise(?:Impl)?)\b", RegexOptions.Compiled);
// Uses of a wrapper type name: return types, "new FooPromiseImpl(", type arguments. Anchored on
// a leading capital so "PromiseLike", bare "Promise" and "trackPromise" are not matched.
private static readonly Regex s_promiseReferencePattern =
new(@"\b[A-Z]\w*Promise(?:Impl)?\b", RegexOptions.Compiled);
private static readonly Regex s_apiDeclarationPattern =
new(@"\b(?:interface|enum|type)\s+([A-Z]\w*)\b", RegexOptions.Compiled);
private static readonly Regex s_apiTypeReferencePattern =
new(@"\b[A-Z]\w*\b", RegexOptions.Compiled);
/// <summary>
/// Removes line and block comments from generated TypeScript. Deliberately simple: generated
/// code has no string literals containing comment delimiters.
/// </summary>
private static string StripComments(string typeScript) =>
Regex.Replace(Regex.Replace(typeScript, @"/\*.*?\*/", string.Empty, RegexOptions.Singleline), @"//[^\n]*", string.Empty);
/// <summary>
/// Removes comments and quoted runtime values so only TypeScript syntax remains for reference scanning.
/// </summary>
private static string StripCommentsAndStringLiterals(string typeScript) =>
Regex.Replace(
StripComments(typeScript),
@"(?s)(?:""(?:\\.|[^""\\])*""|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`)",
string.Empty);
private static AtsCapabilityInfo CreateSameNameResourceCapability<TResource>(
string methodName,
AtsCapabilityInfo distributedApplicationBuilderCapability)
where TResource : IResource
{
var resourceType = typeof(TResource);
var resourceTypeRef = new AtsTypeRef
{
TypeId = $"{resourceType.Assembly.GetName().Name}/{resourceType.FullName}",
ClrType = resourceType,
Category = AtsTypeCategory.Handle
};
return new AtsCapabilityInfo
{
CapabilityId = $"Aspire.Hosting.CodeGeneration.TypeScript.Tests/{methodName}",
MethodName = methodName,
Parameters = [],
ReturnType = resourceTypeRef,
TargetTypeId = distributedApplicationBuilderCapability.TargetTypeId,
TargetType = distributedApplicationBuilderCapability.TargetType,
TargetParameterName = distributedApplicationBuilderCapability.TargetParameterName,
ReturnsBuilder = true
};
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void GenerateDistributedApplication_EveryRpcHandleMatchesTheConstructedWrapper(bool includeHostingAssembly)
{
// Companion invariant to GenerateDistributedApplication_EveryReferencedPromiseWrapperIsDeclared.
// Declaring the wrapper is not enough: the handle flowing out of the RPC must also be
// assignable to the wrapper implementation that consumes it. Handle<T> carries its ATS type
// ID as a literal-typed $type, so FooHandle and IFooHandle are distinct, non-assignable
// types even though CreateBuilderModels collapses IFoo and Foo onto the same generated Foo
// class. Emitting invokeCapability<IFooHandle> and then passing that result to
// FooImpl's constructor produces "TS2345: Argument of type 'IFooHandle' is not assignable
// to parameter of type 'FooHandle'", which breaks "aspire run" just as thoroughly as a
// missing declaration.
var atsContext = includeHostingAssembly ? CreateContextFromBothAssemblies() : CreateContextFromTestAssembly();
var files = _generator.GenerateDistributedApplication(atsContext);
AssertRpcHandlesMatchConstructedWrappers(files["aspire.mts"]);
}
/// <summary>
/// Asserts that every RPC result flowing straight into a wrapper implementation constructor uses
/// the handle type that constructor declares.
/// </summary>
private static void AssertRpcHandlesMatchConstructedWrappers(string generatedAspireTs)
{
var aspireTs = StripComments(generatedAspireTs);
// class FooImpl extends ResourceBuilderBase<FooHandle> ... { constructor(handle: FooHandle, ...
var constructorHandleTypes = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (Match match in s_implementationConstructorPattern.Matches(aspireTs))
{
constructorHandleTypes[match.Groups[1].Value] = match.Groups[2].Value;
}
// const handle = await client.invokeCapability<FooHandle>( '...', rpcArgs );
// return new FooImpl(handle, client);
var mismatches = new List<string>();
var pairs = 0;
foreach (Match match in s_rpcHandleConstructionPattern.Matches(aspireTs))
{
var invokedHandleType = match.Groups[1].Value;
var implementationClass = match.Groups[2].Value;
pairs++;
if (!constructorHandleTypes.TryGetValue(implementationClass, out var expectedHandleType))
{
mismatches.Add($"{implementationClass} is constructed from invokeCapability<{invokedHandleType}> but its constructor handle type was not recognized");
continue;
}
if (!string.Equals(invokedHandleType, expectedHandleType, StringComparison.Ordinal))
{
mismatches.Add($"{implementationClass} takes {expectedHandleType} but is constructed from invokeCapability<{invokedHandleType}>");
}
}
// Guard against a vacuous pass if either pattern stops matching the generated shape.
Assert.NotEmpty(constructorHandleTypes);
Assert.NotEqual(0, pairs);
Assert.True(
mismatches.Count == 0,
$"Generated aspire.mts constructs wrapper implementations from mismatched handle types: {string.Join("; ", mismatches.Order(StringComparer.Ordinal))}");
}
// Resource wrappers use "constructor(handle: FooHandle, ...)", while type-class wrappers use
// "constructor(private _handle: FooHandle, ...)". Capture the class and its first handle type.
private static readonly Regex s_implementationConstructorPattern =
new(
@"\bclass\s+(\w+)\b[^{]*\{\s*constructor\((?:(?:public|private|protected|readonly)\s+)*\w+:\s*(\w+)",
RegexOptions.Compiled);
// An RPC result flowing straight into a wrapper implementation constructor. The intervening
// capability ID and rpcArgs contain no ';', so [^;]*? stops at the invokeCapability call's own
// statement terminator.
private static readonly Regex s_rpcHandleConstructionPattern =
new(@"invokeCapability<(\w+)>\([^;]*?\);\s*return new (\w+)\(", RegexOptions.Compiled);
[Fact]
public async Task TwoPassScanning_GeneratesWithEnvironmentOnTestRedisBuilder()
{
// End-to-end test: verify that withEnvironment appears on TestRedisResourceBuilder
// in the generated TypeScript when using 2-pass scanning.
var atsContext = CreateContextFromBothAssemblies();
// Generate TypeScript
var files = _generator.GenerateDistributedApplication(atsContext);
var aspireTs = files["aspire.mts"];
// Verify withEnvironment appears on TestRedisResource class
// The generated code should have a TestRedisResource class with withEnvironment method
Assert.Contains("class TestRedisResource", aspireTs);
Assert.Contains("withEnvironment", aspireTs);
// Snapshot for detailed verification
await Verify(aspireTs, extension: "ts")
.UseFileName("TwoPassScanningGeneratedAspire");
}
[Fact]
public void TwoPassScanning_DeduplicatesExpandedUnionTypes()
{
var atsContext = CreateContextFromBothAssemblies();
var files = _generator.GenerateDistributedApplication(atsContext);
var aspireTs = files["aspire.mts"];
var lines = aspireTs.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
Assert.DoesNotContain("ResourceBuilderBase | ResourceBuilderBase", aspireTs);
Assert.DoesNotContain("EndpointReference | EndpointReference", aspireTs);
Assert.Contains(lines, line => line.StartsWith("withEnvironment(name: string, value: string | ReferenceExpression | EndpointReference | ", StringComparison.Ordinal));
Assert.Contains(lines, line => line.Contains("withEnvironment(name: string, value:", StringComparison.Ordinal) &&
line.Contains("ExternalServiceResource", StringComparison.Ordinal));
Assert.Contains("ResourceWithConnectionString", aspireTs);
Assert.DoesNotContain("value: string | ReferenceExpression | EndpointReference | ParameterResource | ResourceBuilderBase | EndpointReferenceExpression", aspireTs);
}
[Fact]
public void GenerateDistributedApplication_WithDtoCallbackOptions_MarshalsNestedCallbackProperties()
{
var atsContext = CreateContextFromBothAssemblies();
var files = _generator.GenerateDistributedApplication(atsContext);
var aspireTs = files["aspire.mts"];
var processCommandExportOptions = Assert.Single(atsContext.DtoTypes, dto => dto.Name == "ProcessCommandExportOptions");
var createProcessSpec = Assert.Single(processCommandExportOptions.Properties, property => property.Name == "CreateProcessSpec");
Assert.True(createProcessSpec.IsOptional);
Assert.Contains("const ____optionsForRpcPrepareRequestId = ____optionsForRpcPrepareRequest ? registerCallback", aspireTs);
Assert.Contains("createProcessSpec?: (arg: ExecuteCommandContext) => Promise<ProcessCommandSpecExportData>;", aspireTs);
Assert.Contains("const ____optionsForRpcCreateProcessSpecId = ____optionsForRpcCreateProcessSpec ? registerCallback", aspireTs);
Assert.Contains("__optionsForRpcData[\"createProcessSpec\"] = ____optionsForRpcCreateProcessSpecId;", aspireTs);
Assert.Contains("@deprecated Use withProcessCommand with createProcessSpec in the options object instead.", aspireTs);
Assert.Contains("const ____optionsForRpcCommandOptions = __optionsForRpc.commandOptions;", aspireTs);
Assert.Contains("const ____optionsForRpcCommandOptionsForRpc = { ...____optionsForRpcCommandOptions };", aspireTs);
Assert.Contains("const ______optionsForRpcCommandOptionsForRpcValidateArgumentsId = ______optionsForRpcCommandOptionsForRpcValidateArguments ? registerCallback", aspireTs);
Assert.Contains("const ______optionsForRpcCommandOptionsForRpcUpdateStateId = ______optionsForRpcCommandOptionsForRpcUpdateState ? registerCallback", aspireTs);
Assert.Contains("__optionsForRpcData[\"commandOptions\"] = ____optionsForRpcCommandOptionsForRpc;", aspireTs);
}
[Fact]
public void Scanner_AzureProvisioningCallbacks_ExposeTypedCustomizationProperties()
{
var capabilities = ScanCapabilitiesFromAzureAssemblies();
var publishAsWebsite = Assert.Single(capabilities, c => c.CapabilityId == "Aspire.Hosting.Azure.AppService/publishAsAzureAppServiceWebsite");
AssertCallbackParameterTypes(publishAsWebsite, "configure", typeof(AzureResourceInfrastructure), typeof(WebSite));
AssertCallbackParameterTypes(publishAsWebsite, "configureSlot", typeof(AzureResourceInfrastructure), typeof(WebSiteSlot));
var publishAsContainerAppJob = Assert.Single(capabilities, c => c.CapabilityId == "Aspire.Hosting.Azure.AppContainers/publishAsAzureContainerAppJob");
AssertCallbackParameterTypes(publishAsContainerAppJob, "configure", typeof(AzureResourceInfrastructure), typeof(ContainerAppJob));
AssertTargetedMethod(capabilities, "Aspire.Hosting.Azure.AppService/configureWebSiteSiteConfig", "configureSiteConfig", typeof(WebSite), GetRequiredType("Aspire.Hosting.Azure.AzureAppServiceSiteConfig, Aspire.Hosting.Azure.AppService"));
AssertTargetedMethod(capabilities, "Aspire.Hosting.Azure.AppService/configureWebSiteSlotSiteConfig", "configureSlotSiteConfig", typeof(WebSiteSlot), GetRequiredType("Aspire.Hosting.Azure.AzureAppServiceSiteConfig, Aspire.Hosting.Azure.AppService"));
AssertTargetedMethod(capabilities, "Aspire.Hosting.Azure.AppContainers/configureContainerAppScale", "configureScale", typeof(ContainerApp), GetRequiredType("Aspire.Hosting.Azure.AzureContainerAppScaleConfig, Aspire.Hosting.Azure.AppContainers"));
}
[Fact]
public void Scanner_AzureExistingResourceScopes_ExposeTypeScriptCapabilities()
{
var capabilities = ScanCapabilitiesFromAzureAssemblies();
AssertAzureExistingResourceScopeCapability(capabilities, "runAsExistingInResourceGroup", ["name", "resourceGroup", "subscription"]);
AssertAzureExistingResourceScopeCapability(capabilities, "publishAsExistingInResourceGroup", ["name", "resourceGroup", "subscription"]);
AssertAzureExistingResourceScopeCapability(capabilities, "asExistingInResourceGroup", ["name", "resourceGroup", "subscription"]);
AssertAzureExistingResourceScopeCapability(capabilities, "runAsExistingInSubscription", ["name", "subscription"]);
AssertAzureExistingResourceScopeCapability(capabilities, "publishAsExistingInSubscription", ["name", "subscription"]);
AssertAzureExistingResourceScopeCapability(capabilities, "asExistingInSubscription", ["name", "subscription"]);
AssertAzureExistingResourceScopeCapability(capabilities, "runAsExistingInTenant", ["name"]);
AssertAzureExistingResourceScopeCapability(capabilities, "publishAsExistingInTenant", ["name"]);
AssertAzureExistingResourceScopeCapability(capabilities, "asExistingInTenant", ["name"]);
}
[Fact]
public void GenerateDistributedApplication_WithAzureExistingResourceScopes_EmitsTypeScriptMethods()
{
var result = AtsCapabilityScanner.ScanAssemblies(LoadAzureAssemblies());
var files = _generator.GenerateDistributedApplication(result.ToAtsContext());
var aspireTs = files["aspire.mts"];
Assert.Contains("runAsExistingInResourceGroup", aspireTs);
Assert.Contains("publishAsExistingInResourceGroup", aspireTs);
Assert.Contains("asExistingInResourceGroup", aspireTs);
Assert.Contains("runAsExistingInSubscription", aspireTs);
Assert.Contains("publishAsExistingInSubscription", aspireTs);
Assert.Contains("asExistingInSubscription", aspireTs);
Assert.Contains("runAsExistingInTenant", aspireTs);
Assert.Contains("publishAsExistingInTenant", aspireTs);
Assert.Contains("asExistingInTenant", aspireTs);
}
private static List<AtsCapabilityInfo> ScanCapabilitiesFromTestAssembly()
{
var testAssembly = LoadTestAssembly();
// Scan capabilities from the test assembly
var result = AtsCapabilityScanner.ScanAssembly(testAssembly);
return result.Capabilities;
}
private static AtsContext CreateContextFromTestAssembly()
{
var testAssembly = LoadTestAssembly();
// Scan capabilities from the test assembly
var result = AtsCapabilityScanner.ScanAssembly(testAssembly);
return result.ToAtsContext();
}
private static AtsContext WithAdditionalCapabilities(AtsContext context, params AtsCapabilityInfo[] capabilities)
{
return new AtsContext
{
Capabilities = [.. context.Capabilities, .. capabilities],
HandleTypes = context.HandleTypes,
DtoTypes = context.DtoTypes,
EnumTypes = context.EnumTypes,
ExportedValues = context.ExportedValues,
Diagnostics = context.Diagnostics
};
}
private static AtsTypeRef CreateResourceTypeRef<TResource>() where TResource : IResource =>
new()
{
TypeId = GetAtsTypeId(typeof(TResource)),
ClrType = typeof(TResource),
Category = AtsTypeCategory.Handle
};
private static AtsCapabilityInfo CreateVoidEntryPointCapability(string methodName, params AtsParameterInfo[] parameters) =>
new()
{
CapabilityId = $"Aspire.Hosting.CodeGeneration.TypeScript.Tests/{methodName}",
MethodName = methodName,
Parameters = parameters,
ReturnType = new AtsTypeRef
{
TypeId = AtsConstants.Void,
Category = AtsTypeCategory.Primitive
},
CapabilityKind = AtsCapabilityKind.Method
};
private static AtsCapabilityInfo CreateDistributedApplicationBuilderCapability(
AtsContext context,
string methodName,
string? description,
AtsDocumentationInfo documentation)
{
var addTestRedis = context.Capabilities.First(c => c.CapabilityId == "Aspire.Hosting.CodeGeneration.TypeScript.Tests/addTestRedis");
return new AtsCapabilityInfo
{
CapabilityId = $"Aspire.Hosting.CodeGeneration.TypeScript.Tests/{methodName}",
MethodName = methodName,
Description = description,
Documentation = documentation,
Parameters = [],
ReturnType = new AtsTypeRef
{
TypeId = AtsConstants.Void,
Category = AtsTypeCategory.Primitive
},
TargetTypeId = addTestRedis.TargetTypeId,
TargetType = addTestRedis.TargetType,
TargetParameterName = addTestRedis.TargetParameterName,
ExpandedTargetTypes = addTestRedis.ExpandedTargetTypes,
CapabilityKind = AtsCapabilityKind.Method
};
}
private static Assembly LoadTestAssembly()
{
// Get the test assembly at runtime
return typeof(TestRedisResource).Assembly;
}
private static List<AtsCapabilityInfo> ScanCapabilitiesFromHostingAssembly()
{
var hostingAssembly = typeof(DistributedApplication).Assembly;
var result = AtsCapabilityScanner.ScanAssembly(hostingAssembly);
return result.Capabilities;
}
private static List<AtsCapabilityInfo> ScanCapabilitiesFromBrowsersAssembly()
{
var browsersAssembly = typeof(global::Aspire.Hosting.BrowserLogsBuilderExtensions).Assembly;
var result = AtsCapabilityScanner.ScanAssembly(browsersAssembly);
return result.Capabilities;
}
private static AtsContext CreateContextFromHostingAssembly()
{
var hostingAssembly = typeof(DistributedApplication).Assembly;
var result = AtsCapabilityScanner.ScanAssembly(hostingAssembly);
return result.ToAtsContext();
}
private static List<AtsCapabilityInfo> ScanCapabilitiesFromBothAssemblies()
{
var (testAssembly, hostingAssembly) = LoadBothAssemblies();
// Use ScanAssemblies for proper cross-assembly expansion
var result = AtsCapabilityScanner.ScanAssemblies([hostingAssembly, testAssembly]);
return result.Capabilities;
}
private static AtsContext CreateContextFromBothAssemblies()
{
var (testAssembly, hostingAssembly) = LoadBothAssemblies();
// Use ScanAssemblies for proper cross-assembly expansion and enum collection
var result = AtsCapabilityScanner.ScanAssemblies([hostingAssembly, testAssembly]);
return result.ToAtsContext();
}
private static List<AtsCapabilityInfo> ScanCapabilitiesFromAzureAssemblies()
{
var result = AtsCapabilityScanner.ScanAssemblies(LoadAzureAssemblies());
return result.Capabilities;
}
private static Assembly[] LoadAzureAssemblies()
{
return
[
typeof(DistributedApplication).Assembly,
typeof(AzureResourceInfrastructure).Assembly,
typeof(global::Aspire.Hosting.AzureContainerAppProjectExtensions).Assembly,
typeof(global::Aspire.Hosting.AzureAppServiceComputeResourceExtensions).Assembly
];
}
private static void AssertCallbackParameterTypes(AtsCapabilityInfo capability, string parameterName, params Type[] expectedTypes)
{
var parameter = Assert.Single(capability.Parameters, p => p.Name == parameterName);
Assert.True(parameter.IsCallback);
Assert.NotNull(parameter.CallbackParameters);
Assert.Equal(expectedTypes.Select(GetAtsTypeId), parameter.CallbackParameters.Select(p => p.Type?.TypeId));
}
private static void AssertTargetedMethod(IReadOnlyList<AtsCapabilityInfo> capabilities, string capabilityId, string methodName, Type targetType, Type parameterType)
{
var capability = Assert.Single(capabilities, c => c.CapabilityId == capabilityId);
var parameter = Assert.Single(capability.Parameters);
Assert.Equal(methodName, capability.MethodName);
Assert.Equal(GetAtsTypeId(targetType), capability.TargetTypeId);
Assert.Equal(GetAtsTypeId(parameterType), parameter.Type?.TypeId);
}
private static void AssertAzureExistingResourceScopeCapability(IReadOnlyList<AtsCapabilityInfo> capabilities, string methodName, string[] parameterNames)
{
var capability = Assert.Single(capabilities, c => c.CapabilityId == $"Aspire.Hosting.Azure/{methodName}");
Assert.Equal(methodName, capability.MethodName);
Assert.Equal(GetAtsTypeId(typeof(IAzureResource)), capability.TargetTypeId);
Assert.True(capability.ReturnsBuilder);
Assert.Equal(parameterNames, capability.Parameters.Select(p => p.Name));
}
private static Type GetRequiredType(string assemblyQualifiedTypeName)
{
return Type.GetType(assemblyQualifiedTypeName, throwOnError: true)!;
}
private static string GetAtsTypeId(Type type)
{
return type switch
{
_ when type == typeof(string) => "string",
_ when type == typeof(bool) => "boolean",
_ when type == typeof(byte) || type == typeof(short) || type == typeof(int) || type == typeof(long) ||
type == typeof(float) || type == typeof(double) || type == typeof(decimal) => "number",
_ => $"{type.Assembly.GetName().Name}/{type.FullName}"
};
}
private static (Assembly testAssembly, Assembly hostingAssembly) LoadBothAssemblies()
{
var testAssembly = typeof(TestRedisResource).Assembly;
var hostingAssembly = typeof(DistributedApplication).Assembly;
return (testAssembly, hostingAssembly);
}
[Fact]
public void Scanner_HostingAssembly_CollectionIntrinsicsAreRegistered()
{
// This test verifies that collection intrinsic capabilities (Dict.*, List.*)
// are properly scanned from CollectionExports.cs in Aspire.Hosting.
//
// This is a regression test for a bug where methods with 'object' parameters
// were being skipped because MapToAtsTypeId didn't handle System.Object.
var capabilities = ScanCapabilitiesFromHostingAssembly();
// Verify all Dict.* intrinsics are registered
var dictCapabilities = new[]
{
"Aspire.Hosting/Dict.get",
"Aspire.Hosting/Dict.set",
"Aspire.Hosting/Dict.remove",
"Aspire.Hosting/Dict.keys",
"Aspire.Hosting/Dict.has",
"Aspire.Hosting/Dict.count",
"Aspire.Hosting/Dict.clear",
"Aspire.Hosting/Dict.values",
"Aspire.Hosting/Dict.toObject"
};
foreach (var expectedId in dictCapabilities)
{
var capability = capabilities.FirstOrDefault(c => c.CapabilityId == expectedId);
Assert.NotNull(capability);
}
// Verify all List.* intrinsics are registered
var listCapabilities = new[]
{
"Aspire.Hosting/List.get",
"Aspire.Hosting/List.set",
"Aspire.Hosting/List.add",
"Aspire.Hosting/List.removeAt",
"Aspire.Hosting/List.length",
"Aspire.Hosting/List.clear",
"Aspire.Hosting/List.insert",
"Aspire.Hosting/List.indexOf",
"Aspire.Hosting/List.toArray"
};
foreach (var expectedId in listCapabilities)
{
var capability = capabilities.FirstOrDefault(c => c.CapabilityId == expectedId);
Assert.NotNull(capability);
}
}
[Fact]
public void Generate_HostingAssembly_IncludesCoreFrameworkPolyglotHelpers()
{
var atsContext = CreateContextFromHostingAssembly();
var files = _generator.GenerateDistributedApplication(atsContext);
var aspireTs = files["aspire.mts"];
Assert.Contains("getSection", aspireTs);
Assert.Contains("getChildren", aspireTs);
Assert.Contains("exists", aspireTs);
Assert.Contains("getLoggerFactory", aspireTs);
Assert.Contains("createLogger", aspireTs);
Assert.Contains("getResourceLoggerService", aspireTs);
Assert.Contains("getResourceCommandService", aspireTs);
Assert.Contains("executeCommandAsync", aspireTs);
Assert.Contains("ExecuteCommandResult", aspireTs);
Assert.Contains("getResourceNotificationService", aspireTs);
Assert.Contains("getDistributedApplicationModel", aspireTs);
Assert.Contains("subscribeBeforeStart", aspireTs);
Assert.Contains("subscribeAfterResourcesCreated", aspireTs);
Assert.Contains("subscribeBeforePublish", aspireTs);
Assert.Contains("subscribeAfterPublish", aspireTs);
Assert.Contains("onBeforePublish", aspireTs);
Assert.Contains("onAfterPublish", aspireTs);
Assert.Contains("onBeforeResourceStarted", aspireTs);
Assert.Contains("onResourceStopped", aspireTs);
Assert.Contains("onConnectionStringAvailable", aspireTs);
Assert.Contains("onInitializeResource", aspireTs);
Assert.Contains("onResourceEndpointsAllocated", aspireTs);
Assert.Contains("onResourceReady", aspireTs);
Assert.Contains("getUserSecretsManager", aspireTs);
Assert.Contains("getEventing", aspireTs);
Assert.Contains("saveStateJson", aspireTs);
}
[Fact]
public void Scanner_ObjectParameter_MapsToAny()
{
// This test verifies that 'object' parameters are correctly mapped to 'any' type.
// Regression test for Dict.set capability being skipped.
var capabilities = ScanCapabilitiesFromHostingAssembly();
// Dict.set has an 'object value' parameter - it should be mapped to 'any'
var dictSet = capabilities.FirstOrDefault(c => c.CapabilityId == "Aspire.Hosting/Dict.set");
Assert.NotNull(dictSet);
// Find the 'value' parameter
var valueParam = dictSet.Parameters.FirstOrDefault(p => p.Name == "value");
Assert.NotNull(valueParam);
// Type should be 'any'
Assert.NotNull(valueParam.Type);
Assert.Equal("any", valueParam.Type.TypeId);
}
[Fact]
public void AspireUnionAttribute_ParsesCorrectly()
{
// This test verifies that [AspireUnion] attributes are correctly parsed using runtime reflection
var envCallbackContextType = typeof(EnvironmentCallbackContext);
Assert.NotNull(envCallbackContextType);
// Find the EnvironmentVariables property
var envVarsProperty = envCallbackContextType.GetProperty("EnvironmentVariables");
Assert.NotNull(envVarsProperty);
// Get the [AspireUnion] attribute
var unionAttr = envVarsProperty.GetCustomAttributes(false)
.FirstOrDefault(a => a.GetType().FullName == "Aspire.Hosting.AspireUnionAttribute");
Assert.NotNull(unionAttr);
// Get the Types property from the attribute using reflection
var typesProperty = unionAttr.GetType().GetProperty("Types");
Assert.NotNull(typesProperty);
var types = typesProperty.GetValue(unionAttr) as Type[];
Assert.NotNull(types);
Assert.Equal(2, types.Length);
// First type should be System.String
Assert.Equal(typeof(string), types[0]);
// Second type should be ReferenceExpression
Assert.Contains("ReferenceExpression", types[1].FullName ?? types[1].Name);
}
// ===== CapabilityKind Tests =====
[Fact]
public void Scanner_InstanceMethod_HasCorrectCapabilityKind()
{
// TestResourceContext has ExposeMethods=true - its methods should be CapabilityKind.InstanceMethod
var capabilities = ScanCapabilitiesFromBothAssemblies();
var getValueAsync = capabilities.First(c =>
c.CapabilityId == "Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes/TestResourceContext.getValueAsync");
Assert.Equal(AtsCapabilityKind.InstanceMethod, getValueAsync.CapabilityKind);
}
[Fact]
public void Scanner_ReferenceExpressionGetValueAsync_IsExported()
{
var capabilities = ScanCapabilitiesFromHostingAssembly();
var getValueAsync = capabilities.FirstOrDefault(c =>
c.CapabilityId == "Aspire.Hosting.ApplicationModel/getValueAsync" &&
c.TargetTypeId == AtsConstants.ReferenceExpressionTypeId);
Assert.NotNull(getValueAsync);
Assert.Equal(AtsCapabilityKind.InstanceMethod, getValueAsync.CapabilityKind);
}
[Fact]
public void Scanner_ExtensionMethod_HasCorrectCapabilityKind()
{
// Extension methods should be CapabilityKind.Method
var capabilities = ScanCapabilitiesFromTestAssembly();
var addTestRedis = capabilities.First(c =>
c.CapabilityId == "Aspire.Hosting.CodeGeneration.TypeScript.Tests/addTestRedis");
Assert.Equal(AtsCapabilityKind.Method, addTestRedis.CapabilityKind);
}
// ===== Thenable Pattern Code Generation Tests =====
[Fact]
public void Generate_TypeWithMethods_CreatesThenableWrapper()
{
var code = GenerateTwoPassCode();
// TestResourceContext has ExposeMethods=true - gets Promise wrapper
Assert.Contains("class TestResourceContextPromiseImpl implements TestResourceContextPromise", code);
Assert.Contains("implements TestResourceContextPromise", code);
}
[Fact]
public void Generate_TypeWithOnlyProperties_NoThenableWrapper()
{
var code = GenerateTwoPassCode();
// TestEnvironmentContext has only ExposeProperties=true - no Promise wrapper
Assert.DoesNotContain("TestEnvironmentContextPromise", code);
}
[Fact]
public void Generate_VoidInstanceMethod_ReturnsContainingTypePromise()
{
var code = GenerateTwoPassCode();
// setValueAsync returns void but chains as TestResourceContextPromise
Assert.Contains("setValueAsync(value: string): TestResourceContextPromise", code);
}
[Fact]
public void Generate_PrimitiveReturningMethod_ReturnsPlainPromise()
{
var code = GenerateTwoPassCode();
// getValueAsync returns string - plain Promise, not a wrapper
Assert.Contains("getValueAsync(): Promise<string>", code);
}
[Fact]
public void GenerateTwoPassCode_UsesUnifiedWithReferenceSurface()
{
var code = GenerateTwoPassCode();
Assert.DoesNotContain("withServiceReference(", code);
Assert.DoesNotContain("withServiceReferenceNamed(", code);
Assert.Contains("name?: string;", code);
}
private string GenerateTwoPassCode()
{
var atsContext = CreateContextFromBothAssemblies();
var files = _generator.GenerateDistributedApplication(atsContext);
return files["aspire.mts"];
}
// ===== CancellationToken Tests =====
[Fact]
public void Scanner_CancellationToken_MapsToCorrectTypeId()
{
// Verify CancellationToken parameters map to AtsConstants.CancellationToken
var capabilities = ScanCapabilitiesFromTestAssembly();
var getStatusAsync = capabilities
.FirstOrDefault(c => c.CapabilityId == "Aspire.Hosting.CodeGeneration.TypeScript.Tests/getStatusAsync");
Assert.NotNull(getStatusAsync);
// Find the cancellationToken parameter
var ctParam = getStatusAsync.Parameters.FirstOrDefault(p => p.Name == "cancellationToken");
Assert.NotNull(ctParam);
Assert.NotNull(ctParam.Type);
Assert.Equal(AtsConstants.CancellationToken, ctParam.Type.TypeId);
Assert.Equal(AtsTypeCategory.Primitive, ctParam.Type.Category);
}
[Fact]
public void Generate_MethodWithCancellationToken_GeneratesCancellationTokenParameter()
{
// Generated input parameters should accept AbortSignal for user-authored cancellation,
// while callbacks and returned values use the structural SDK cancellation token interface.
var code = GenerateTwoPassCode();
Assert.Contains("cancellationToken?: AbortSignal | CancellationToken;", code);
Assert.Contains("set: async (value: AbortSignal | CancellationToken): Promise<void> => {", code);
Assert.Contains("withCancellableOperation(operation: (arg: CancellationToken) => Promise<void>)", code);
}
[Fact]
public void Scanner_CancellationTokenInCallback_MapsCorrectly()
{
// Verify CancellationToken in callback parameters maps correctly
var capabilities = ScanCapabilitiesFromTestAssembly();
var withCancellableOperation = capabilities
.FirstOrDefault(c => c.CapabilityId == "Aspire.Hosting.CodeGeneration.TypeScript.Tests/withCancellableOperation");
Assert.NotNull(withCancellableOperation);
// Find the callback parameter
var operationParam = withCancellableOperation.Parameters.FirstOrDefault(p => p.Name == "operation");
Assert.NotNull(operationParam);
Assert.True(operationParam.IsCallback);
// The callback should have a CancellationToken parameter
Assert.NotNull(operationParam.CallbackParameters);
Assert.Single(operationParam.CallbackParameters);
Assert.Equal(AtsConstants.CancellationToken, operationParam.CallbackParameters[0].Type?.TypeId);
}
[Fact]
public void Scanner_CancellationTokenWithOtherParams_AllParamsPresent()
{
// Verify CancellationToken mixed with other parameters all get mapped
var capabilities = ScanCapabilitiesFromTestAssembly();
var waitForReadyAsync = capabilities
.FirstOrDefault(c => c.CapabilityId == "Aspire.Hosting.CodeGeneration.TypeScript.Tests/waitForReadyAsync");
Assert.NotNull(waitForReadyAsync);
// Should have timeout and cancellationToken parameters
Assert.Equal(2, waitForReadyAsync.Parameters.Count);
var timeoutParam = waitForReadyAsync.Parameters.FirstOrDefault(p => p.Name == "timeout");
Assert.NotNull(timeoutParam);
Assert.Equal(AtsConstants.TimeSpan, timeoutParam.Type?.TypeId);
var ctParam = waitForReadyAsync.Parameters.FirstOrDefault(p => p.Name == "cancellationToken");
Assert.NotNull(ctParam);
Assert.Equal(AtsConstants.CancellationToken, ctParam.Type?.TypeId);
Assert.True(ctParam.IsOptional);
}
// ===== DTO Generation Tests =====
[Fact]
public void Scanner_AspireDtoType_IsDiscovered()
{
// Verify [AspireDto] types are discovered during scanning
var atsContext = CreateContextFromTestAssembly();
// Check that TestConfigDto is in the DTO types
var testConfigDto = atsContext.DtoTypes
.FirstOrDefault(d => d.TypeId.Contains("TestConfigDto"));
Assert.NotNull(testConfigDto);
// Should have expected properties
Assert.Contains(testConfigDto.Properties, p => p.Name == "Name" || p.Name == "name");
Assert.Contains(testConfigDto.Properties, p => p.Name == "Port" || p.Name == "port");
Assert.Contains(testConfigDto.Properties, p => p.Name == "Enabled" || p.Name == "enabled");
}
[Fact]
public void Generate_AspireDtoType_GeneratesInterface()
{
// Verify [AspireDto] types generate TypeScript interfaces
var code = GenerateTwoPassCode();
// TestConfigDto should generate an interface
// Note: The generated code may use PascalCase or camelCase depending on JSON naming policy
Assert.Contains("interface TestConfigDto", code);
}
[Fact]
public void Generate_NestedDtoType_GeneratesCorrectTypes()
{
// Verify nested DTOs are handled correctly
var code = GenerateTwoPassCode();
// TestNestedDto should generate an interface with nested types
Assert.Contains("interface TestNestedDto", code);
Assert.Contains("tags?: string[];", code);
Assert.Contains("counts?: Record<string, number>;", code);
}
[Fact]
public void Scanner_DeeplyNestedDto_IsDiscovered()
{
// Verify deeply nested generic DTOs are discovered
var atsContext = CreateContextFromTestAssembly();
var deeplyNestedDto = atsContext.DtoTypes
.FirstOrDefault(d => d.TypeId.Contains("TestDeeplyNestedDto"));
Assert.NotNull(deeplyNestedDto);
}
// ===== Enum Generation Tests =====
[Fact]
public void Scanner_EnumType_IsDiscovered()
{
// Verify enum types are discovered when used in capabilities
var atsContext = CreateContextFromTestAssembly();
// Check that TestResourceStatus enum is discovered
var testResourceStatus = atsContext.EnumTypes
.FirstOrDefault(e => e.TypeId.Contains("TestResourceStatus"));
Assert.NotNull(testResourceStatus);
// Should have expected values
Assert.Contains("Pending", testResourceStatus.Values);
Assert.Contains("Running", testResourceStatus.Values);
Assert.Contains("Stopped", testResourceStatus.Values);
Assert.Contains("Failed", testResourceStatus.Values);
}
[Fact]
public void Generate_EnumType_GeneratesStringEnum()
{
// Verify enums generate TypeScript string enums
var code = GenerateTwoPassCode();
// TestResourceStatus should generate an enum
Assert.Contains("enum TestResourceStatus", code);
}
// ===== Diagnostics Tests =====
[Fact]
public void Scanner_ProducesDiagnosticsForInvalidTypes()
{
// Note: This test verifies the diagnostic infrastructure works.
// The scanner produces warnings for capabilities with unmapped types.
var testAssembly = LoadTestAssembly();
var result = AtsCapabilityScanner.ScanAssembly(testAssembly);
// Diagnostics should be a non-null list (may be empty if all types are valid)
Assert.NotNull(result.Diagnostics);
}
[Fact]
public void Scanner_CapabilityWithValidTypes_NoDiagnostics()
{
// Verify that well-formed capabilities don't produce diagnostics
var capabilities = ScanCapabilitiesFromTestAssembly();
// addTestRedis is a well-formed capability
var addTestRedis = capabilities
.FirstOrDefault(c => c.CapabilityId == "Aspire.Hosting.CodeGeneration.TypeScript.Tests/addTestRedis");
Assert.NotNull(addTestRedis);
// It should have valid parameter types
foreach (var param in addTestRedis.Parameters)
{
Assert.NotNull(param.Type);
Assert.NotEqual(AtsTypeCategory.Unknown, param.Type.Category);
}
}
[Fact]
public void Generate_ListProperty_GeneratesGetterOnlyMethods()
{
// Verify that List properties on [AspireExport(ExposeProperties = true)] types
// generate zero-argument methods (same pattern as Dictionary properties with AspireDict)
var atsContext = CreateContextFromTestAssembly();
var files = _generator.GenerateDistributedApplication(atsContext);
var code = files["aspire.mts"];
// TestCollectionContext has both Items (List) and Metadata (Dictionary)
// Both should use the same getter-only method pattern with lazy initialization.
// Check for AspireList getter-only method pattern.
Assert.Contains("private _items?: AspireList<string>;", code);
Assert.Contains("async items(): Promise<AspireList<string>>", code);
Assert.Contains("this._items = new AspireList<string>(", code);
// Check for AspireDict getter-only method pattern.
Assert.Contains("private _metadata?: AspireDict<string, string>;", code);
Assert.Contains("async metadata(): Promise<AspireDict<string, string>>", code);
Assert.Contains("this._metadata = new AspireDict<string, string>(", code);
}
[Fact]
public void Generate_ListProperty_DoesNotUsePropertyObjectPattern()
{
// Verify that getter-only List properties do not use the old property object pattern.
var atsContext = CreateContextFromTestAssembly();
var files = _generator.GenerateDistributedApplication(atsContext);
var code = files["aspire.mts"];
// Should NOT contain the old pattern for items
Assert.DoesNotContain("items = {", code);
Assert.DoesNotContain("items = {\n get: async", code);
}
[Fact]
public void Generate_OptionalOptionsProperty_UsesDistinctOptionsBagParameter()
{
var code = GenerateTwoPassCode();
Assert.DoesNotContain("= options?.options;", code);
Assert.Contains("addProject(name: string, projectPath: string, options?: AddProjectOptions)", code);
Assert.Contains("let launchProfileOrOptions = options?.launchProfileOrOptions;", code);
}
[Fact]
public void Generate_MutableCollectionProperties_UsePropertyAccessors()
{
var atsContext = CreateContextFromTestAssembly();
var files = _generator.GenerateDistributedApplication(atsContext);
var code = files["aspire.mts"];
Assert.Contains("readonly tags: AspireList<string>;", code);
Assert.Contains("get tags(): AspireList<string> {", code);
Assert.Contains("readonly counts: AspireDict<string, number>;", code);
Assert.Contains("get counts(): AspireDict<string, number> {", code);
Assert.DoesNotContain("async tags(): Promise<AspireList<string>>", code);
Assert.DoesNotContain("async counts(): Promise<AspireDict<string, number>>", code);
}
[Fact]
public void Generate_ConcreteAndInterfaceWithSameClassName_NoDuplicateClasses()
{
// TestVaultResource (concrete) and ITestVaultResource (interface) both derive
// to the same TypeScript class name "TestVaultResource". The codegen must emit
// exactly one class definition, preferring the concrete type.
var atsContext = CreateContextFromTestAssembly();
var files = _generator.GenerateDistributedApplication(atsContext);
var code = files["aspire.mts"];
// Count occurrences of the public interface definition.
var classCount = CountOccurrences(code, "export interface TestVaultResource ");
Assert.Equal(1, classCount);
// Also verify the Promise wrapper interface is not duplicated.
var promiseCount = CountOccurrences(code, "export interface TestVaultResourcePromise ");
Assert.Equal(1, promiseCount);
}
[Fact]
public void Generate_ResourceAndResourceNamedPromise_NoDuplicateDeclarations()
{
// A zero-capability resource named Foo does not need a Promise wrapper when it is only
// referenced as a parameter. Generating one would collide with a real FooPromise resource.
var collisionCapability = CreateVoidEntryPointCapability(
"inspectPromiseNameCollision",
new AtsParameterInfo
{
Name = "resource",
Type = CreateResourceTypeRef<TestPromiseNameCollisionResource>()
},
new AtsParameterInfo
{
Name = "promiseResource",
Type = CreateResourceTypeRef<TestPromiseNameCollisionResourcePromise>()
});
var atsContext = WithAdditionalCapabilities(CreateContextFromTestAssembly(), collisionCapability);
var files = _generator.GenerateDistributedApplication(atsContext);
var code = files["aspire.mts"];
Assert.Equal(1, CountOccurrences(code, "export interface TestPromiseNameCollisionResourcePromise "));
Assert.Equal(1, CountOccurrences(code, "class TestPromiseNameCollisionResourcePromiseImpl "));
}
// ===== Options Interface Merging Tests =====
[Fact]
public async Task Generate_SameMethodNameOnDifferentTypes_MergesOptionsInterface()
{
// Regression test: When the same method name (e.g., withDataVolume) appears on
// multiple resource types with different optional parameters, the generated options
// interface must be the union of all parameters across all overloads.
// Previously, RegisterOptionsInterface used first-write-wins, so the interface
// only included parameters from whichever overload was registered first.
var code = GenerateTwoPassCode();
// Extract just the WithDataVolumeOptions interface for snapshot verification.
var interfaceStart = code.IndexOf("export interface WithDataVolumeOptions", StringComparison.Ordinal);
Assert.True(interfaceStart >= 0, "WithDataVolumeOptions interface not found in generated code");
var interfaceEnd = code.IndexOf("}", interfaceStart, StringComparison.Ordinal);
var interfaceBody = code[interfaceStart..(interfaceEnd + 1)];
await Verify(interfaceBody, extension: "ts")
.UseFileName("WithDataVolumeOptionsMerged");
}
private static int CountOccurrences(string text, string pattern)
{
var count = 0;
var index = 0;
while ((index = text.IndexOf(pattern, index, StringComparison.Ordinal)) != -1)
{
count++;
index += pattern.Length;
}
return count;
}
// ===== JavaScript Assembly Expansion Tests =====
[Fact]
public void Scanner_WithNpm_ExpandsToAllJavaScriptResourceTypes()
{
// Verify that withNpm (constrained to JavaScriptAppResource) expands to all three
// concrete JS resource types: JavaScriptAppResource, NodeAppResource, ViteAppResource.
// This is a regression test for capability ID expansion where concrete types
// were not registered under their own type ID in the compatibility map.
var hostingAssembly = typeof(DistributedApplication).Assembly;
var jsAssembly = typeof(Aspire.Hosting.JavaScript.JavaScriptAppResource).Assembly;
var result = AtsCapabilityScanner.ScanAssemblies([hostingAssembly, jsAssembly]);
var withNpm = result.Capabilities
.FirstOrDefault(c => c.CapabilityId == "Aspire.Hosting.JavaScript/withNpm");
Assert.NotNull(withNpm);
var expandedTypeIds = withNpm.ExpandedTargetTypes.Select(t => t.TypeId).ToList();
// All three JS resource types should be present
var javaScriptAppTypeId = AtsTypeMapping.DeriveTypeId(typeof(Aspire.Hosting.JavaScript.JavaScriptAppResource));
var nodeAppTypeId = AtsTypeMapping.DeriveTypeId(typeof(Aspire.Hosting.JavaScript.NodeAppResource));
var viteAppTypeId = AtsTypeMapping.DeriveTypeId(typeof(Aspire.Hosting.JavaScript.ViteAppResource));
Assert.Contains(javaScriptAppTypeId, expandedTypeIds);
Assert.Contains(nodeAppTypeId, expandedTypeIds);
Assert.Contains(viteAppTypeId, expandedTypeIds);
}
[Theory]
[InlineData("withNpm")]
[InlineData("withBun")]
[InlineData("withYarn")]
[InlineData("withPnpm")]
public void Scanner_PackageManagerMethods_ExpandToAllJavaScriptResourceTypes(string methodName)
{
// Verify all package manager methods expand to the known JS resource types.
// Assert the minimum expected set rather than an exact count so the test
// remains valid when new JavaScriptAppResource-derived types are added.
var hostingAssembly = typeof(DistributedApplication).Assembly;
var jsAssembly = typeof(Aspire.Hosting.JavaScript.JavaScriptAppResource).Assembly;
var result = AtsCapabilityScanner.ScanAssemblies([hostingAssembly, jsAssembly]);
var capability = result.Capabilities
.FirstOrDefault(c => c.CapabilityId == $"Aspire.Hosting.JavaScript/{methodName}");
Assert.NotNull(capability);
var expandedTypeIds = capability.ExpandedTargetTypes.Select(t => t.TypeId).ToList();
Assert.True(expandedTypeIds.Count >= 3, $"Expected at least 3 expanded types but found {expandedTypeIds.Count}");
Assert.Contains(expandedTypeIds,
id => id.Contains(nameof(JavaScript.JavaScriptAppResource), StringComparison.Ordinal)
&& !id.Contains("NodeApp", StringComparison.Ordinal)
&& !id.Contains("ViteApp", StringComparison.Ordinal));
Assert.Contains(expandedTypeIds, id => id.Contains(nameof(JavaScript.NodeAppResource), StringComparison.Ordinal));
Assert.Contains(expandedTypeIds, id => id.Contains(nameof(JavaScript.ViteAppResource), StringComparison.Ordinal));
}
private const string ApiExportPackageName = "Aspire.Hosting.CodeGeneration.TypeScript.Tests";
private const string ApiExportPackageVersion = "13.5.0";
[Fact]
public async Task ApiExportWriterProducesFocusedCanonicalJson()
{
var model = new TypeScriptApiModel
{
SchemaVersion = 1,
Language = "typescript",
Generator = new TypeScriptApiGeneratorIdentity("Aspire.Hosting.CodeGeneration.TypeScript", "13.5.0"),
Package = new TypeScriptApiPackageIdentity("Aspire.Hosting.Contoso", "1.2.3"),
Modules =
[
new TypeScriptApiModule
{
Name = "index",
Items =
[
new TypeScriptApiItem
{
Id = "interface:ContosoResource",
TypeId = "Aspire.Hosting.Contoso/ContosoResource",
Kind = TypeScriptApiItemKind.Interface,
Name = "ContosoResource",
Declaration = "export interface ContosoResource",
OwningAssemblyName = "Aspire.Hosting.Contoso",
Summary = "A Contoso resource.",
Members =
[
new TypeScriptApiMember
{
Id = "member:ContosoResource.configure",
Kind = TypeScriptApiItemKind.Method,
Name = "configure",
Declaration = "configure(enabled?: boolean): Promise<void>",
CapabilityId = "Aspire.Hosting.Contoso/configure",
OwningAssemblyName = "Aspire.Hosting.Contoso",
Parameters =
[
new TypeScriptApiParameter
{
Name = "enabled",
DeclaredType = "boolean",
IsOptional = true,
Summary = "Whether configuration is enabled."
}
],
ReturnType = "Promise<void>"
}
]
}
]
}
],
Declarations =
[
new TypeScriptApiDeclaration
{
Id = "interface:ContosoResource",
Content = "export interface ContosoResource {\r\n configure(enabled?: boolean): Promise<void>;\r\n}",
OwningAssemblyName = "Aspire.Hosting.Contoso"
}
]
};
var json = TypeScriptApiExportWriter.WriteToJson(model, indented: true);
await Verify(json, extension: "json")
.UseFileName("AtsTypeScriptCodeGeneratorTests.FocusedApiExport");
}
[Fact]
public void ApiExportIncludesCodeGeneratorIdentity()
{
var model = ProjectApi(CreateEntryPointContext(ApiExportPackageName), ApiExportPackageName);
using var document = System.Text.Json.JsonDocument.Parse(TypeScriptApiExportWriter.WriteToJson(model));
var generator = document.RootElement.GetProperty("generator");
var assembly = typeof(AtsTypeScriptCodeGenerator).Assembly;
Assert.Equal(assembly.GetName().Name, generator.GetProperty("name").GetString());
Assert.Equal(
assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()!.InformationalVersion,
generator.GetProperty("version").GetString());
}
[Fact]
public void ApiExportIncludesExportedValuesWithGeneratedDeclaration()
{
var context = CreateContextFromTestAssembly();
var generatedSource = _generator.GenerateDistributedApplication(context)["aspire.mts"];
using var document = System.Text.Json.JsonDocument.Parse(
TypeScriptApiExportWriter.WriteToJson(ProjectApi(context, ApiExportPackageName)));
var items = document.RootElement.GetProperty("modules")[0].GetProperty("items").EnumerateArray();
var testConfigs = Assert.Single(
items,
item => item.GetProperty("name").GetString() == "TestConfigs");
Assert.Equal("namespace", testConfigs.GetProperty("kind").GetString());
Assert.Equal("export namespace TestConfigs", testConfigs.GetProperty("declaration").GetString());
var defaultConfig = Assert.Single(
testConfigs.GetProperty("members").EnumerateArray(),
member => member.GetProperty("name").GetString() == "Default");
const string expectedDeclaration =
"export const Default = { name: \"default\", port: 6379, enabled: true, optionalField: \"cache\" } as TestConfigDto";
Assert.Equal("constant", defaultConfig.GetProperty("kind").GetString());
Assert.Equal(expectedDeclaration, defaultConfig.GetProperty("declaration").GetString());
Assert.Equal("The default test configuration.", defaultConfig.GetProperty("summary").GetString());
Assert.Contains($"{expectedDeclaration};", generatedSource, StringComparison.Ordinal);
}
[Fact]
public void ApiExportPromiseDeclarationContainsOnlySourcePromiseMembers()
{
var targetType = new AtsTypeRef
{
TypeId = $"{ApiExportPackageName}/PromiseContext",
Category = AtsTypeCategory.Handle
};
var stringType = new AtsTypeRef
{
TypeId = AtsConstants.String,
Category = AtsTypeCategory.Primitive
};
var voidType = new AtsTypeRef
{
TypeId = AtsConstants.Void,
Category = AtsTypeCategory.Primitive
};
var context = CreateApiContext(
new AtsCapabilityInfo
{
CapabilityId = $"{ApiExportPackageName}/PromiseContext.readOnly.get",
MethodName = "readOnly",
OwningTypeName = "PromiseContext",
Parameters = [],
ReturnType = stringType,
TargetTypeId = targetType.TypeId,
TargetType = targetType,
ExpandedTargetTypes = [],
CapabilityKind = AtsCapabilityKind.PropertyGetter
},
new AtsCapabilityInfo
{
CapabilityId = $"{ApiExportPackageName}/PromiseContext.mutable.get",
MethodName = "mutable",
OwningTypeName = "PromiseContext",
Parameters = [],
ReturnType = stringType,
TargetTypeId = targetType.TypeId,
TargetType = targetType,
ExpandedTargetTypes = [],
CapabilityKind = AtsCapabilityKind.PropertyGetter
},
new AtsCapabilityInfo
{
CapabilityId = $"{ApiExportPackageName}/PromiseContext.mutable.set",
MethodName = "setMutable",
OwningTypeName = "PromiseContext",
Parameters = [new AtsParameterInfo { Name = "value", Type = stringType }],
ReturnType = voidType,
TargetTypeId = targetType.TypeId,
TargetType = targetType,
ExpandedTargetTypes = [],
CapabilityKind = AtsCapabilityKind.PropertySetter
},
new AtsCapabilityInfo
{
CapabilityId = $"{ApiExportPackageName}/PromiseContext.run",
MethodName = "run",
OwningTypeName = "PromiseContext",
Parameters = [],
ReturnType = voidType,
TargetTypeId = targetType.TypeId,
TargetType = targetType,
ExpandedTargetTypes = [],
CapabilityKind = AtsCapabilityKind.InstanceMethod
});
var declaration = Assert.Single(
ProjectApi(context, ApiExportPackageName).Declarations,
declaration => declaration.Content.StartsWith(
"export interface PromiseContextPromise ",
StringComparison.Ordinal));
var expectedDeclaration = """
export interface PromiseContextPromise extends PromiseLike<PromiseContext> {
readOnly(): Promise<string>;
run(): PromiseContextPromise;
}
""".ReplaceLineEndings("\n");
Assert.Equal(expectedDeclaration, declaration.Content);
Assert.Contains(
expectedDeclaration,
_generator.GenerateDistributedApplication(context)["aspire.mts"].ReplaceLineEndings("\n"),
StringComparison.Ordinal);
}
[Fact]
public void ApiReferenceExporterRequiresAndHonorsCancellation()
{
var method = typeof(IApiReferenceExporter).GetMethod(nameof(IApiReferenceExporter.ExportApi));
Assert.NotNull(method);
Assert.Collection(
method.GetParameters(),
parameter => Assert.Equal(typeof(AtsContext), parameter.ParameterType),
parameter => Assert.Equal(typeof(ApiReferenceExportOptions), parameter.ParameterType),
parameter =>
{
Assert.Equal(typeof(CancellationToken), parameter.ParameterType);
Assert.False(parameter.HasDefaultValue);
});
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
var context = new AtsContext
{
Capabilities = null!,
HandleTypes = [],
DtoTypes = [],
EnumTypes = []
};
IApiReferenceExporter exporter = new AtsTypeScriptApiReferenceExporter();
Assert.Throws<OperationCanceledException>(() => exporter.ExportApi(
context,
new ApiReferenceExportOptions(
ApiExportPackageName,
ApiExportPackageVersion,
[ApiExportPackageName]),
cancellation.Token));
}
[Fact]
public void ApiReferenceExportOptionsCopiesExportingAssemblyNames()
{
var exportingAssemblyNames = new List<string> { ApiExportPackageName };
var options = new ApiReferenceExportOptions(
ApiExportPackageName,
ApiExportPackageVersion,
exportingAssemblyNames);
exportingAssemblyNames.Clear();
Assert.Equal(ApiExportPackageName, Assert.Single(options.ExportingAssemblyNames));
}
[Fact]
public void ApiReferenceExportOptionsExposesReadOnlyExportingAssemblyNames()
{
var options = new ApiReferenceExportOptions(
ApiExportPackageName,
ApiExportPackageVersion,
[ApiExportPackageName]);
var exportingAssemblyNames = Assert.IsAssignableFrom<IList<string>>(options.ExportingAssemblyNames);
Assert.Throws<NotSupportedException>(() => exportingAssemblyNames[0] = "Changed");
}
[Fact]
public void ApiReferenceExportOptionsRequiresAnExportingAssembly()
{
Assert.Throws<ArgumentException>(() => new ApiReferenceExportOptions(
ApiExportPackageName,
ApiExportPackageVersion,
[]));
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void ApiReferenceExportOptionsRequiresValidExportingAssemblyNames(string? exportingAssemblyName)
{
Assert.Throws<ArgumentException>(() => new ApiReferenceExportOptions(
ApiExportPackageName,
ApiExportPackageVersion,
[exportingAssemblyName!]));
}
[Fact]
public void ApiExportEntrypointIdsIncludeTheOwningAssembly()
{
const string firstPackage = "Aspire.Hosting.Contoso.EntryPoints";
const string secondPackage = "Aspire.Hosting.Fabrikam.EntryPoints";
var firstModel = ProjectApi(CreateEntryPointContext(firstPackage), firstPackage);
var first = Assert.Single(firstModel.Modules.SelectMany(module => module.Items));
var second = Assert.Single(ProjectApi(CreateEntryPointContext(secondPackage), secondPackage)
.Modules.SelectMany(module => module.Items));
Assert.Equal($"entrypoint:{firstPackage}:startThing", first.Id);
Assert.Equal($"entrypoint:{secondPackage}:startThing", second.Id);
Assert.NotEqual(first.Id, second.Id);
Assert.Equal(
"function startThing(client: AspireClientRpc, name: string, retries?: number): Promise<void>",
first.Declaration);
var declaration = Assert.Single(
firstModel.Declarations,
declaration => declaration.Id == $"{firstPackage}:entrypoint:startThing");
Assert.Equal(
"export declare function startThing(client: AspireClientRpc, name: string, retries?: number): Promise<void>;",
declaration.Content);
var generatedSource = new AtsTypeScriptCodeGenerator()
.GenerateDistributedApplication(CreateEntryPointContext(firstPackage))["aspire.mts"];
Assert.Contains($"export async {first.Declaration} {{", generatedSource, StringComparison.Ordinal);
}
[Fact]
public void ApiExportKeepsPackageLocalOptionsNamesAndCombinedGenerationIsDeterministic()
{
const string eventHubsPackage = "Aspire.Hosting.Azure.EventHubs";
const string serviceBusPackage = "Aspire.Hosting.Azure.ServiceBus";
var eventHubsCapability = CreateRunAsEmulatorCapability(
eventHubsPackage,
"DistributedApplicationBuilder",
new AtsTypeRef
{
TypeId = $"{eventHubsPackage}/AzureEventHubsEmulatorResource",
Category = AtsTypeCategory.Handle
});
var serviceBusCapability = CreateRunAsEmulatorCapability(
serviceBusPackage,
"DistributedApplicationBuilder",
new AtsTypeRef
{
TypeId = $"{serviceBusPackage}/AzureServiceBusEmulatorResource",
Category = AtsTypeCategory.Handle
});
var eventHubsModel = ProjectApi(CreateApiContext(eventHubsCapability), eventHubsPackage);
var serviceBusModel = ProjectApi(CreateApiContext(serviceBusCapability), serviceBusPackage);
var eventHubsOptions = Assert.Single(
eventHubsModel.Declarations,
declaration => declaration.Content.StartsWith("export interface RunAsEmulatorOptions ", StringComparison.Ordinal));
var serviceBusOptions = Assert.Single(
serviceBusModel.Declarations,
declaration => declaration.Content.StartsWith("export interface RunAsEmulatorOptions ", StringComparison.Ordinal));
Assert.Equal(
(eventHubsPackage, ApiExportPackageVersion, $"{eventHubsPackage}:options:RunAsEmulatorOptions"),
(eventHubsModel.Package.Name, eventHubsModel.Package.Version, eventHubsOptions.Id));
Assert.Equal(
(serviceBusPackage, ApiExportPackageVersion, $"{serviceBusPackage}:options:RunAsEmulatorOptions"),
(serviceBusModel.Package.Name, serviceBusModel.Package.Version, serviceBusOptions.Id));
Assert.Equal(
"""
export interface RunAsEmulatorOptions {
configure?: (emulator: AzureEventHubsEmulatorResourceHandle) => Promise<void>;
}
""".ReplaceLineEndings("\n"),
eventHubsOptions.Content);
Assert.Equal(
"""
export interface RunAsEmulatorOptions {
configure?: (emulator: AzureServiceBusEmulatorResourceHandle) => Promise<void>;
}
""".ReplaceLineEndings("\n"),
serviceBusOptions.Content);
Assert.NotEqual(eventHubsOptions.Content, serviceBusOptions.Content);
AssertApiDeclarationsAreSelfContained(eventHubsModel);
AssertApiDeclarationsAreSelfContained(serviceBusModel);
Assert.Contains(
"runAsEmulator(options?: RunAsEmulatorOptions)",
Assert.Single(
eventHubsModel.Modules.SelectMany(module => module.Items).SelectMany(item => item.Members),
member => member.CapabilityId == eventHubsCapability.CapabilityId).Declaration,
StringComparison.Ordinal);
Assert.Contains(
"runAsEmulator(options?: RunAsEmulatorOptions)",
Assert.Single(
serviceBusModel.Modules.SelectMany(module => module.Items).SelectMany(item => item.Members),
member => member.CapabilityId == serviceBusCapability.CapabilityId).Declaration,
StringComparison.Ordinal);
var forwardContext = CreateApiContext(eventHubsCapability, serviceBusCapability);
var reverseContext = CreateApiContext(serviceBusCapability, eventHubsCapability);
var forwardSource = _generator.GenerateDistributedApplication(forwardContext)["aspire.mts"];
var reverseSource = _generator.GenerateDistributedApplication(reverseContext)["aspire.mts"];
Assert.Equal(forwardSource, reverseSource);
var combinedPackage = new TypeScriptApiPackageIdentity("Aspire.Hosting.Combined", ApiExportPackageVersion);
var combinedAssemblies = new[] { eventHubsPackage, serviceBusPackage };
var forwardModel = new TypeScriptApiProjector(forwardContext)
.BuildApiModel(combinedPackage, combinedAssemblies, CancellationToken.None);
var reverseModel = new TypeScriptApiProjector(reverseContext)
.BuildApiModel(combinedPackage, combinedAssemblies, CancellationToken.None);
Assert.Equal(
TypeScriptApiExportWriter.WriteToJson(forwardModel),
TypeScriptApiExportWriter.WriteToJson(reverseModel));
var methodsByCapability = forwardModel.Modules
.SelectMany(module => module.Items)
.SelectMany(item => item.Members)
.Where(member => member.CapabilityId is not null)
.ToDictionary(member => member.CapabilityId!, StringComparer.Ordinal);
Assert.Contains(
"options?: RunAsEmulatorOptions",
methodsByCapability[eventHubsCapability.CapabilityId].Declaration,
StringComparison.Ordinal);
Assert.Contains(
"options?: RunAsEmulator1Options",
methodsByCapability[serviceBusCapability.CapabilityId].Declaration,
StringComparison.Ordinal);
}
[Fact]
public void ApiExportExplicitInterfaceMemberNameMatchesItsDeclaration()
{
const string targetTypeId = ApiExportPackageName + "/Contoso.WidgetContext";
var targetType = new AtsTypeRef
{
TypeId = targetTypeId,
Category = AtsTypeCategory.Handle
};
var context = new AtsContext
{
Capabilities =
[
new AtsCapabilityInfo
{
CapabilityId = ApiExportPackageName + "/IWidget.configure",
MethodName = "IWidget.configure",
OwningTypeName = "WidgetContext",
Parameters = [],
ReturnType = new AtsTypeRef
{
TypeId = AtsConstants.Void,
Category = AtsTypeCategory.Primitive
},
TargetTypeId = targetTypeId,
TargetType = targetType,
ExpandedTargetTypes = [],
CapabilityKind = AtsCapabilityKind.InstanceMethod
}
],
HandleTypes = [],
DtoTypes = [],
EnumTypes = [],
ExportedValues = [],
Diagnostics = []
};
var member = Assert.Single(ProjectApi(context, ApiExportPackageName)
.Modules.SelectMany(module => module.Items)
.SelectMany(item => item.Members));
Assert.Equal("configure", member.Name);
Assert.StartsWith($"{member.Name}(", member.Declaration, StringComparison.Ordinal);
}
[Fact]
public void ApiExportUsesGeneratedSignaturesAndSeparatesReferencedTypes()
{
var context = CreateContextFromBothAssemblies();
var model = ProjectApi(context, ApiExportPackageName);
var generatedSource = new AtsTypeScriptCodeGenerator()
.GenerateDistributedApplication(context)["aspire.mts"];
var items = model.Modules.SelectMany(module => module.Items).ToList();
Assert.NotEmpty(items);
Assert.All(
items.Where(item => item.Kind != TypeScriptApiItemKind.Augmentation),
item => Assert.Equal(ApiExportPackageName, item.OwningAssemblyName));
Assert.All(
items.SelectMany(item => item.Members).Where(member => member.Kind == TypeScriptApiItemKind.Method),
member => Assert.Contains(member.Declaration, generatedSource, StringComparison.Ordinal));
var itemIds = items.Select(item => item.Id).ToList();
Assert.Equal(itemIds.Count, itemIds.Distinct(StringComparer.Ordinal).Count());
Assert.Contains(
model.Declarations,
declaration => declaration.OwningAssemblyName == "Aspire.Hosting");
Assert.All(
model.Declarations,
declaration => Assert.DoesNotContain('\r', declaration.Content));
}
private static TypeScriptApiModel ProjectApi(AtsContext context, string packageName)
=> new TypeScriptApiProjector(context).BuildApiModel(
new TypeScriptApiPackageIdentity(packageName, ApiExportPackageVersion),
[packageName],
CancellationToken.None);
private static void AssertApiDeclarationsAreSelfContained(TypeScriptApiModel model)
{
var completeSource = string.Join("\n", model.Declarations.Select(declaration => declaration.Content));
var declared = s_apiDeclarationPattern.Matches(completeSource)
.Select(match => match.Groups[1].Value)
.ToHashSet(StringComparer.Ordinal);
// The runtime declaration is a fixed compiler baseline. Scan every package-produced fragment
// after removing comments and branded-handle string literals so only TypeScript names remain.
var packageSource = string.Join(
"\n",
model.Declarations
.Where(declaration => declaration.Id != "aspire:runtime:base")
.Select(declaration => declaration.Content));
var referenced = s_apiTypeReferencePattern.Matches(StripCommentsAndStringLiterals(packageSource))
.Select(match => match.Value)
.ToHashSet(StringComparer.Ordinal);
Assert.NotEmpty(declared);
Assert.NotEmpty(referenced);
referenced.ExceptWith(declared);
referenced.ExceptWith(["Promise", "PromiseLike"]);
Assert.True(
referenced.Count == 0,
$"Package '{model.Package.Name}' declarations reference type(s) that are never declared: " +
string.Join(", ", referenced.Order(StringComparer.Ordinal)));
}
private static AtsContext CreateApiContext(params AtsCapabilityInfo[] capabilities)
=> new()
{
Capabilities = capabilities,
HandleTypes = [],
DtoTypes = [],
EnumTypes = [],
ExportedValues = [],
Diagnostics = []
};
private static AtsCapabilityInfo CreateRunAsEmulatorCapability(
string packageName,
string targetTypeName,
AtsTypeRef callbackPayloadType)
{
var targetType = new AtsTypeRef
{
TypeId = $"Aspire.Hosting/{targetTypeName}",
Category = AtsTypeCategory.Handle
};
return new AtsCapabilityInfo
{
CapabilityId = $"{packageName}/runAsEmulator",
MethodName = "runAsEmulator",
Parameters =
[
new AtsParameterInfo
{
Name = "configure",
Type = new AtsTypeRef
{
TypeId = "callback",
Category = AtsTypeCategory.Callback
},
IsOptional = true,
IsCallback = true,
CallbackParameters =
[
new AtsCallbackParameterInfo
{
Name = "emulator",
Type = callbackPayloadType
}
],
CallbackReturnType = new AtsTypeRef
{
TypeId = AtsConstants.Void,
Category = AtsTypeCategory.Primitive
}
}
],
ReturnType = new AtsTypeRef
{
TypeId = AtsConstants.Void,
Category = AtsTypeCategory.Primitive
},
TargetTypeId = targetType.TypeId,
TargetType = targetType,
ExpandedTargetTypes = [],
CapabilityKind = AtsCapabilityKind.InstanceMethod
};
}
private static AtsContext CreateEntryPointContext(string packageName)
{
return new AtsContext
{
Capabilities =
[
new AtsCapabilityInfo
{
CapabilityId = $"{packageName}/startThing",
MethodName = "startThing",
Parameters =
[
new AtsParameterInfo
{
Name = "name",
Type = new AtsTypeRef
{
TypeId = AtsConstants.String,
Category = AtsTypeCategory.Primitive
}
},
new AtsParameterInfo
{
Name = "retries",
Type = new AtsTypeRef
{
TypeId = AtsConstants.Number,
Category = AtsTypeCategory.Primitive
},
IsOptional = true
}
],
ReturnType = new AtsTypeRef
{
TypeId = AtsConstants.Void,
Category = AtsTypeCategory.Primitive
},
ExpandedTargetTypes = [],
CapabilityKind = AtsCapabilityKind.Method
}
],
HandleTypes = [],
DtoTypes = [],
EnumTypes = [],
ExportedValues = [],
Diagnostics = []
};
}
}