File: Commands\Sdk\SdkExportCommandTests.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.Commands;
using Aspire.Cli.Configuration;
using Aspire.Cli.Interaction;
using Aspire.Cli.Projects;
using Aspire.Cli.Tests.TestServices;
using Aspire.Cli.Tests.Utils;
using Aspire.Cli.Utils;
using Microsoft.AspNetCore.InternalTesting;
using Microsoft.Extensions.DependencyInjection;
using StreamJsonRpc;
using StreamJsonRpc.Protocol;
 
namespace Aspire.Cli.Tests.Commands.Sdk;
 
public class SdkExportCommandTests(ITestOutputHelper outputHelper)
{
    [Fact]
    public async Task SdkExportWithHelpReturnsZero()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider = services.BuildServiceProvider();
 
        var exitCode = await InvokeAsync(provider, "sdk export --help");
 
        Assert.Equal(CliExitCodes.Success, exitCode);
    }
 
    [Theory]
    [InlineData("typescript/nodejs")]
    [InlineData("typescript")]
    [InlineData("TypeScript")]
    public async Task SdkExportSendsTheResolvedGeneratorName(string language)
    {
        var interactionService = new TestInteractionService();
        using var provider = CreateProvider(
            interactionService,
            out var workspace,
            out var rpcClient,
            out _);
        using var workspaceLease = workspace;
 
        var exitCode = await InvokeAsync(provider, $"sdk export --language {language}");
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.Equal("TypeScript", Assert.NotNull(rpcClient.LastExportRequest).Language);
    }
 
    [Fact]
    public async Task SdkExportRestoresExactPackageAndWritesOnlyJsonToStdout()
    {
        var interactionService = new TestInteractionService();
        using var provider = CreateProvider(
            interactionService,
            out var workspace,
            out var rpcClient,
            out var project);
        using var workspaceLease = workspace;
 
        var exitCode = await InvokeAsync(
            provider,
            "sdk export --language typescript --package Contoso.Aspire.Widgets@2.0");
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.Equal(("TypeScript", "Contoso.Aspire.Widgets", "2.0.0"), rpcClient.LastExportRequest);
 
        var package = Assert.Single(
            project.Integrations,
            integration => integration.Name == "Contoso.Aspire.Widgets");
        Assert.Equal("[2.0.0]", package.Version);
        Assert.True(package.DisableLocalProjectSubstitution);
 
        var generator = Assert.Single(
            project.Integrations,
            integration => integration.Name.Contains("CodeGeneration", StringComparison.OrdinalIgnoreCase));
        var cliVersion = provider.GetRequiredService<CliExecutionContext>().IdentityVersion;
        Assert.Equal(cliVersion, generator.Version);
 
        Assert.Equal(ConsoleOutput.Error, interactionService.Console);
        var stdout = Assert.Single(
            interactionService.DisplayedRawText,
            entry => entry.ConsoleOverride == ConsoleOutput.Standard);
        Assert.DoesNotContain('\r', stdout.Text);
 
        using var document = JsonDocument.Parse(stdout.Text);
        Assert.Equal("Contoso.Aspire.Widgets", document.RootElement.GetProperty("package").GetProperty("name").GetString());
        Assert.DoesNotContain(
            interactionService.DisplayedMessages,
            message => (message.ConsoleOverride ?? interactionService.Console) == ConsoleOutput.Standard);
    }
 
    [Theory]
    [InlineData("1.2.3.4", "1.2.3.4")]
    [InlineData("1.2.3.0", "1.2.3")]
    [InlineData("1.0.0.0-beta", "1.0.0-beta")]
    [InlineData("1.2.3.4-preview.1+meta", "1.2.3.4-preview.1")]
    public async Task SdkExportRestoresNormalizedFourPartNuGetVersion(string requestedVersion, string normalizedVersion)
    {
        var interactionService = new TestInteractionService();
        using var provider = CreateProvider(
            interactionService,
            out var workspace,
            out var rpcClient,
            out var project);
        using var workspaceLease = workspace;
 
        var exitCode = await InvokeAsync(
            provider,
            $"sdk export --language typescript --package Contoso.Aspire.Widgets@{requestedVersion}");
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.Equal(("TypeScript", "Contoso.Aspire.Widgets", normalizedVersion), rpcClient.LastExportRequest);
 
        var package = Assert.Single(
            project.Integrations,
            integration => integration.Name == "Contoso.Aspire.Widgets");
        Assert.Equal($"[{normalizedVersion}]", package.Version);
    }
 
    [Fact]
    public async Task SdkExportRejectsRequestedGeneratorPackageAtDifferentVersion()
    {
        var interactionService = new TestInteractionService();
        using var provider = CreateProvider(
            interactionService,
            out var workspace,
            out var rpcClient,
            out var project,
            identityVersion: "13.5.0");
        using var workspaceLease = workspace;
 
        var exitCode = await InvokeAsync(
            provider,
            "sdk export --language typescript --package Aspire.Hosting.CodeGeneration.TypeScript@13.4.0");
 
        Assert.Equal(CliExitCodes.InvalidCommand, exitCode);
        Assert.Equal(0, project.PrepareCallCount);
        Assert.Null(rpcClient.LastExportRequest);
        Assert.Equal(
            "SDK API export cannot export Aspire.Hosting.CodeGeneration.TypeScript because that package supplies the selected language's code generator instead of an integration API surface.",
            Assert.Single(interactionService.DisplayedErrors));
    }
 
    [Fact]
    public async Task SdkExportRejectsRequestedGeneratorPackageAtCliVersion()
    {
        var interactionService = new TestInteractionService();
        using var provider = CreateProvider(
            interactionService,
            out var workspace,
            out var rpcClient,
            out var project,
            identityVersion: "13.5.0");
        using var workspaceLease = workspace;
 
        var exitCode = await InvokeAsync(
            provider,
            "sdk export --language typescript --package Aspire.Hosting.CodeGeneration.TypeScript@13.5.0.0");
 
        Assert.Equal(CliExitCodes.InvalidCommand, exitCode);
        Assert.Equal(0, project.PrepareCallCount);
        Assert.Null(rpcClient.LastExportRequest);
        Assert.Equal(
            "SDK API export cannot export Aspire.Hosting.CodeGeneration.TypeScript because that package supplies the selected language's code generator instead of an integration API surface.",
            Assert.Single(interactionService.DisplayedErrors));
    }
 
    [Fact]
    public async Task SdkExportDefaultsToCoreAtTheRunningSdkVersion()
    {
        var interactionService = new TestInteractionService();
        using var provider = CreateProvider(
            interactionService,
            out var workspace,
            out var rpcClient,
            out var project);
        using var workspaceLease = workspace;
 
        var exitCode = await InvokeAsync(provider, "sdk export --language typescript");
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        var expectedVersion = provider.GetRequiredService<CliExecutionContext>().IdentitySdkVersion;
        Assert.Equal(("TypeScript", "Aspire.Hosting", expectedVersion), rpcClient.LastExportRequest);
        Assert.DoesNotContain(project.Integrations, integration => integration.Name == "Aspire.Hosting");
    }
 
    [Theory]
    [InlineData("Aspire.Hosting")]
    [InlineData("Aspire.Hosting@")]
    [InlineData("@13.5.0")]
    [InlineData(" @13.5.0")]
    [InlineData("Aspire@Hosting@13.5.0")]
    [InlineData("Contoso@not-a-version")]
    [InlineData("Contoso@13.5.*")]
    [InlineData("Contoso@[13.5.0]")]
    [InlineData("Contoso@1.2.3.4-")]
    [InlineData("Contoso@1.2.3.4+")]
    [InlineData("Contoso@1.2.3.4-preview..1")]
    public async Task SdkExportRejectsMalformedOrNonExactPackages(string package)
    {
        var interactionService = new TestInteractionService();
        using var provider = CreateProvider(
            interactionService,
            out var workspace,
            out var rpcClient,
            out _);
        using var workspaceLease = workspace;
 
        var exitCode = await InvokeAsync(provider, $"sdk export --language typescript --package \"{package}\"");
 
        Assert.Equal(CliExitCodes.InvalidCommand, exitCode);
        Assert.Null(rpcClient.LastExportRequest);
        Assert.Empty(interactionService.DisplayedRawText);
    }
 
    [Fact]
    public async Task SdkExportRejectsCoreVersionDifferentFromTheCli()
    {
        var interactionService = new TestInteractionService();
        using var provider = CreateProvider(
            interactionService,
            out var workspace,
            out var rpcClient,
            out _);
        using var workspaceLease = workspace;
 
        var exitCode = await InvokeAsync(
            provider,
            "sdk export --language typescript --package Aspire.Hosting@0.0.1");
 
        Assert.Equal(CliExitCodes.InvalidCommand, exitCode);
        Assert.Null(rpcClient.LastExportRequest);
    }
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public async Task SdkExportRejectsCoreWhenEmulatedVersionDiffersFromTheRunningBinary(bool specifyPackage)
    {
        var interactionService = new TestInteractionService();
        var physicalSdkVersion = VersionHelper.GetDefaultSdkVersion();
        var emulatedSdkVersion = physicalSdkVersion == "0.0.1" ? "0.0.2" : "0.0.1";
        using var provider = CreateProvider(
            interactionService,
            out var workspace,
            out var rpcClient,
            out _,
            identityVersion: emulatedSdkVersion);
        using var workspaceLease = workspace;
 
        var command = specifyPackage
            ? $"sdk export --language typescript --package Aspire.Hosting@{emulatedSdkVersion}"
            : "sdk export --language typescript";
        var exitCode = await InvokeAsync(provider, command);
 
        Assert.Equal(CliExitCodes.InvalidCommand, exitCode);
        Assert.Null(rpcClient.LastExportRequest);
    }
 
    [Fact]
    public async Task SdkExportUsesStructuredInvalidParametersForUnsupportedLanguage()
    {
        var interactionService = new TestInteractionService();
        var rpcClient = new ThrowingExportRpcClient(new RemoteInvocationException(
            "No code generator found for language: klingon.",
            (int)JsonRpcErrorCode.InvalidParams,
            errorData: null));
        using var provider = CreateProvider(
            interactionService,
            out var workspace,
            rpcClient,
            new CapturingAppHostServerProject());
        using var workspaceLease = workspace;
 
        var exitCode = await InvokeAsync(provider, "sdk export --language klingon");
 
        Assert.Equal(CliExitCodes.InvalidCommand, exitCode);
        Assert.Empty(interactionService.DisplayedRawText);
        Assert.Contains(
            interactionService.DisplayedErrors,
            error => error.Contains("klingon", StringComparison.Ordinal));
    }
 
    [Fact]
    public async Task SdkExportRejectsLanguageWithoutCodeGeneratorBeforePreparation()
    {
        var interactionService = new TestInteractionService();
        using var provider = CreateProvider(
            interactionService,
            out var workspace,
            out var rpcClient,
            out var project);
        using var workspaceLease = workspace;
 
        var exitCode = await InvokeAsync(provider, "sdk export --language csharp");
 
        Assert.Equal(CliExitCodes.InvalidCommand, exitCode);
        Assert.Collection(
            interactionService.DisplayedErrors,
            error => Assert.Equal(
                "SDK API export is not supported for C# (.NET) because it does not use a code generator.",
                error));
        Assert.Equal(0, project.PrepareCallCount);
        Assert.Null(rpcClient.LastExportRequest);
    }
 
    [Fact]
    public async Task SdkExportRpcFailureWritesNoPartialDocument()
    {
        var interactionService = new TestInteractionService();
        var rpcClient = new ThrowingExportRpcClient(
            new RemoteInvocationException("AppHost export failed.", 0, errorData: null));
        using var provider = CreateProvider(
            interactionService,
            out var workspace,
            rpcClient,
            new CapturingAppHostServerProject());
        using var workspaceLease = workspace;
 
        var exitCode = await InvokeAsync(provider, "sdk export --language typescript");
 
        Assert.Equal(CliExitCodes.FailedToBuildArtifacts, exitCode);
        Assert.Empty(interactionService.DisplayedRawText);
    }
 
    [Fact]
    public async Task SdkExportHasNoSourceOrOutputOptions()
    {
        var interactionService = new TestInteractionService();
        using var provider = CreateProvider(
            interactionService,
            out var workspace,
            out var rpcClient,
            out _);
        using var workspaceLease = workspace;
 
        var exitCode = await InvokeAsync(
            provider,
            "sdk export --language typescript --source custom-feed");
 
        Assert.NotEqual(CliExitCodes.Success, exitCode);
        Assert.Null(rpcClient.LastExportRequest);
    }
 
    private static async Task<int> InvokeAsync(ServiceProvider provider, string commandLine)
    {
        var command = provider.GetRequiredService<RootCommand>();
        return await command.Parse(commandLine).InvokeAsync().DefaultTimeout();
    }
 
    private ServiceProvider CreateProvider(
        TestInteractionService interactionService,
        out TemporaryWorkspace workspace,
        out StubExportRpcClient rpcClient,
        out CapturingAppHostServerProject project,
        string? identityVersion = null)
    {
        workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        rpcClient = new StubExportRpcClient();
        project = new CapturingAppHostServerProject();
        return CreateProvider(interactionService, out _, rpcClient, project, workspace, identityVersion);
    }
 
    private ServiceProvider CreateProvider(
        TestInteractionService interactionService,
        out TemporaryWorkspace workspace,
        IAppHostRpcClient rpcClient,
        IAppHostServerProject appHostServerProject,
        TemporaryWorkspace? existingWorkspace = null,
        string? identityVersion = null)
    {
        workspace = existingWorkspace ?? TemporaryWorkspace.CreateForCli(outputHelper);
        var testWorkspace = workspace;
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.InteractionServiceFactory = _ => interactionService;
            if (identityVersion is not null)
            {
                options.CliExecutionContextFactory = _ => testWorkspace.CreateExecutionContext(
                    identityVersion: identityVersion,
                    identityOverridden: true);
            }
        });
 
        services.AddSingleton<IAppHostServerProjectFactory>(new TestAppHostServerProjectFactory
        {
            CreateAsyncCallback = (_, _) => Task.FromResult(appHostServerProject)
        });
        services.AddSingleton<IAppHostServerSessionFactory>(new FakeAppHostServerSessionFactory
        {
            Session = new FakeAppHostServerSession(rpcClient)
        });
 
        return services.BuildServiceProvider();
    }
 
    private sealed class StubExportRpcClient : FakeAppHostRpcClient
    {
        public (string Language, string PackageName, string PackageVersion)? LastExportRequest { get; private set; }
 
        public override Task<JsonElement> ExportApiAsync(
            string languageId,
            string packageName,
            string packageVersion,
            CancellationToken cancellationToken)
        {
            LastExportRequest = (languageId, packageName, packageVersion);
 
            using var document = JsonDocument.Parse($$"""
                {
                  "schemaVersion": 1,
                  "language": "{{languageId}}",
                  "package": { "name": "{{packageName}}", "version": "{{packageVersion}}" },
                  "modules": [],
                  "declarations": []
                }
                """.ReplaceLineEndings("\r\n"));
 
            return Task.FromResult(document.RootElement.Clone());
        }
    }
 
    private sealed class ThrowingExportRpcClient(Exception exception) : FakeAppHostRpcClient
    {
        public override Task<JsonElement> ExportApiAsync(
            string languageId,
            string packageName,
            string packageVersion,
            CancellationToken cancellationToken)
            => Task.FromException<JsonElement>(exception);
    }
 
    private sealed class CapturingAppHostServerProject : IAppHostServerProject
    {
        public string AppDirectoryPath => Environment.CurrentDirectory;
 
        public IReadOnlyList<IntegrationReference> Integrations { get; private set; } = [];
 
        public int PrepareCallCount { get; private set; }
 
        public string GetInstanceIdentifier() => AppDirectoryPath;
 
        public Task<AppHostServerPrepareResult> PrepareAsync(
            string sdkVersion,
            IEnumerable<IntegrationReference> integrations,
            string? requestedChannel = null,
            string? packageSourceOverride = null,
            CancellationToken cancellationToken = default)
        {
            PrepareCallCount++;
            Integrations = [.. integrations];
            return Task.FromResult(new AppHostServerPrepareResult(Success: true, Output: null));
        }
 
        public Task<AppHostServerRunResult> RunAsync(
            int hostPid,
            IReadOnlyDictionary<string, string>? environmentVariables,
            string[]? additionalArgs,
            bool debug,
            AppHostServerRunControl? runControl)
            => throw new NotSupportedException("Run should not be invoked by this test.");
    }
}