File: Scaffolding\DefaultHostingPackagesTests.cs
Web Access
Project: src\tests\Aspire.Cli.Tests\Aspire.Cli.Tests.csproj (Aspire.Cli.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Text.Json;
using Aspire.Cli.Configuration;
using Aspire.Cli.Projects;
using Aspire.Cli.Scaffolding;
using Aspire.Cli.Telemetry;
using Aspire.Cli.Tests.TestServices;
using Aspire.Cli.Tests.Utils;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
 
namespace Aspire.Cli.Tests.Scaffolding;
 
/// <summary>
/// Scaffolding an AppHost records the language's default hosting packages in <c>aspire.config.json</c>
/// and prepares the AppHost server with them, so their resource APIs are generated without an
/// <c>aspire add</c> step.
/// </summary>
public class DefaultHostingPackagesTests(ITestOutputHelper outputHelper)
{
    private const string RequestedSdkVersion = "13.4.0-test";
    private const string IdentitySdkVersion = "13.9.0-identity";
 
    private FakeSucceedingAppHostServerProject? _serverProject;
 
    [Theory]
    [InlineData(KnownLanguageId.TypeScript, "Aspire.Hosting.JavaScript")]
    [InlineData(KnownLanguageId.Python, "Aspire.Hosting.Python")]
    [InlineData(KnownLanguageId.Go, "Aspire.Hosting.Go")]
    public async Task ScaffoldAsync_RecordsAndRestoresTheLanguageDefaultHostingPackage(string languageId, string expectedPackage)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var language = GetLanguage(languageId);
 
        var result = await ScaffoldAsync(workspace, language, RequestedSdkVersion);
 
        Assert.True(result);
        var package = Assert.Single(LoadPackages(workspace));
        Assert.Equal(expectedPackage, package.Key);
        Assert.Equal(RequestedSdkVersion, package.Value.Version);
        var restored = _serverProject!.Integrations.Select(integration => (integration.Name, integration.Version)).ToList();
        Assert.Contains((expectedPackage, RequestedSdkVersion), restored);
        Assert.Contains((language.PackageName, RequestedSdkVersion), restored);
    }
 
    [Fact]
    public async Task ScaffoldAsync_UsesTheCliSdkVersionWhenNoneIsRequested()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var result = await ScaffoldAsync(workspace, GetLanguage(KnownLanguageId.TypeScript), sdkVersion: null);
 
        Assert.True(result);
        var package = Assert.Single(LoadPackages(workspace));
        Assert.Equal("Aspire.Hosting.JavaScript", package.Key);
        Assert.Equal(IdentitySdkVersion, package.Value.Version);
    }
 
    [Fact]
    public async Task ScaffoldAsync_RecordsTheJavaScriptPackageForABrownfieldTypeScriptAppHost()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        await File.WriteAllTextAsync(
            Path.Combine(workspace.WorkspaceRoot.FullName, "package.json"),
            """{ "name": "existing-app" }""",
            TestContext.Current.CancellationToken);
 
        var result = await ScaffoldAsync(workspace, GetLanguage(KnownLanguageId.TypeScript), RequestedSdkVersion);
 
        Assert.True(result);
        var package = Assert.Single(LoadPackages(workspace));
        Assert.Equal("Aspire.Hosting.JavaScript", package.Key);
        Assert.Equal(RequestedSdkVersion, package.Value.Version);
    }
 
    [Fact]
    public async Task ScaffoldAsync_KeepsAnExistingEntryForADefaultHostingPackage()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        await File.WriteAllTextAsync(
            Path.Combine(workspace.WorkspaceRoot.FullName, "aspire.config.json"),
            """{ "packages": { "Aspire.Hosting.JavaScript": "9.9.9" } }""",
            TestContext.Current.CancellationToken);
 
        var result = await ScaffoldAsync(workspace, GetLanguage(KnownLanguageId.TypeScript), RequestedSdkVersion);
 
        Assert.True(result);
        var package = Assert.Single(LoadPackages(workspace));
        Assert.Equal("Aspire.Hosting.JavaScript", package.Key);
        Assert.Equal("9.9.9", package.Value.Version);
    }
 
    [Theory]
    [InlineData(KnownLanguageId.TypeScript, "aspire.hosting.javascript")]
    [InlineData(KnownLanguageId.Python, "aspire.hosting.python")]
    [InlineData(KnownLanguageId.Go, "aspire.hosting.go")]
    public async Task ScaffoldAsync_KeepsAnExistingEntryForADefaultHostingPackageWithADifferentCase(string languageId, string existingPackageName)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        await File.WriteAllTextAsync(
            Path.Combine(workspace.WorkspaceRoot.FullName, "aspire.config.json"),
            $$"""{ "packages": { "{{existingPackageName}}": "9.9.9" } }""",
            TestContext.Current.CancellationToken);
 
        var result = await ScaffoldAsync(workspace, GetLanguage(languageId), RequestedSdkVersion);
 
        Assert.True(result);
        var package = Assert.Single(LoadPackages(workspace));
        Assert.Equal(existingPackageName, package.Key);
        Assert.Equal("9.9.9", package.Value.Version);
    }
 
    [Fact]
    public async Task ScaffoldAsync_RecordsNoPackagesForALanguageWithoutDefaultHostingPackages()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var language = GetLanguage(KnownLanguageId.TypeScript) with { DefaultHostingPackages = [] };
 
        var result = await ScaffoldAsync(workspace, language, RequestedSdkVersion);
 
        Assert.True(result);
        Assert.Empty(LoadPackages(workspace));
        Assert.Collection(
            _serverProject!.Integrations,
            integration => Assert.Equal("Aspire.Hosting", integration.Name),
            integration => Assert.Equal(language.PackageName, integration.Name));
    }
 
    [Theory]
    [InlineData("ts-starter", KnownLanguageId.TypeScript)]
    [InlineData("py-starter", KnownLanguageId.TypeScript)] // The Python starter's AppHost is a TypeScript AppHost.
    [InlineData("go-starter", KnownLanguageId.Go)]
    public void StarterTemplate_DeclaresTheDefaultHostingPackagesOfItsAppHostLanguage(string template, string appHostLanguageId)
    {
        var language = GetLanguage(appHostLanguageId);
        var configPath = Path.Combine(GetRepoRoot(), "src", "Aspire.Cli", "Templating", "Templates", template, "aspire.config.json");
        Assert.True(File.Exists(configPath), $"Expected the {template} aspire.config.json at {configPath}");
 
        using var document = JsonDocument.Parse(File.ReadAllText(configPath));
        var declaredPackages = document.RootElement
            .GetProperty("packages")
            .EnumerateObject()
            .Select(package => package.Name)
            .ToArray();
 
        Assert.NotEmpty(language.DefaultHostingPackages);
        Assert.All(language.DefaultHostingPackages, package => Assert.Contains(package, declaredPackages));
    }
 
    [Fact]
    public void DefaultHostingPackages_AreStablePackagesBuiltByThisRepository()
    {
        var sourceDirectory = Path.Combine(GetRepoRoot(), "src");
 
        Assert.All(
            DefaultLanguageDiscovery.AllLanguages.SelectMany(language => language.DefaultHostingPackages),
            package =>
            {
                var projectPath = Path.Combine(sourceDirectory, package, $"{package}.csproj");
                Assert.True(File.Exists(projectPath), $"Expected {package} to be built from src/{package}.");
 
                // A preview-only package has no version that satisfies the stable CLI version the scaffolder pins.
                // MSBuild does not see this read, so each default package project is also listed in the
                // path_rules of eng/github-ci/test-trigger-map.yml to select this test project.
                Assert.False(
                    File.ReadAllText(projectPath).Contains("SuppressFinalPackageVersion", StringComparison.Ordinal),
                    $"{package} sets SuppressFinalPackageVersion, so it only ships as a preview and cannot be a default hosting package.");
            });
    }
 
    private static LanguageInfo GetLanguage(string languageId)
        => DefaultLanguageDiscovery.AllLanguages.Single(language => language.LanguageId.Value == languageId);
 
    private static Dictionary<string, PackageEntry> LoadPackages(TemporaryWorkspace workspace)
    {
        var config = AspireConfigFile.Load(workspace.WorkspaceRoot.FullName);
        Assert.NotNull(config);
 
        return config.Packages ?? [];
    }
 
    private static string GetRepoRoot()
        => Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", ".."));
 
    private async Task<bool> ScaffoldAsync(TemporaryWorkspace workspace, LanguageInfo language, string? sdkVersion)
    {
        var scaffoldingService = new ScaffoldingService(
            appHostServerProjectFactory: new TestAppHostServerProjectFactory
            {
                CreateAsyncCallback = (path, _) =>
                {
                    _serverProject = new FakeSucceedingAppHostServerProject(path);
                    return Task.FromResult<IAppHostServerProject>(_serverProject);
                }
            },
            serverSessionFactory: FakeAppHostServerSessionFactory.CreateForScaffolding(),
            languageDiscovery: new TestLanguageDiscovery(language),
            interactionService: new TestInteractionService(),
            environment: new TestEnvironment(),
            logger: NullLogger<ScaffoldingService>.Instance,
            executionContext: workspace.CreateExecutionContext(identityVersion: IdentitySdkVersion),
            profilingTelemetry: new ProfilingTelemetry(new ConfigurationBuilder().Build()),
            features: new TestFeatures(),
            processExecutionFactory: new TestProcessExecutionFactory());
 
        var context = new ScaffoldContext(
            Language: language,
            TargetDirectory: workspace.WorkspaceRoot,
            ProjectName: "test",
            SdkVersion: sdkVersion);
 
        return await scaffoldingService.ScaffoldAsync(context, TestContext.Current.CancellationToken);
    }
}