// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using Aspire.Cli.Commands;
using Aspire.Cli.Commands.Sdk;
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 Microsoft.DotNet.RemoteExecutor;
using System.Reflection;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace Aspire.Cli.Tests.Commands;
public class SdkDumpCommandTests(ITestOutputHelper outputHelper)
{
private static readonly RemoteInvokeOptions s_remoteInvokeOptions = new()
{
StartInfo = { RedirectStandardOutput = true }
};
[Fact]
public async Task SdkDumpWithHelpReturnsZero()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
var result = command.Parse("sdk dump --help");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(0, exitCode);
}
[Theory]
[InlineData("json")]
[InlineData("Json")]
[InlineData("JSON")]
[InlineData("ci")]
[InlineData("Ci")]
[InlineData("pretty")]
[InlineData("Pretty")]
public void ParsesFormatOptionWithoutErrors(string format)
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
var result = command.Parse($"sdk dump --format {format}");
Assert.Empty(result.Errors);
}
[Fact]
public async Task SdkDumpWithNonexistentCsprojReturnsFailedToFindProject()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
var result = command.Parse("sdk dump /nonexistent/path/to/integration.csproj");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.FailedToFindProject, exitCode);
}
[Fact]
public async Task SdkDumpWithEmptyPackageNameReturnsInvalidCommand()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
var result = command.Parse("sdk dump @13.2.0");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.InvalidCommand, exitCode);
}
[Fact]
public async Task SdkDumpWithEmptyVersionReturnsInvalidCommand()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
var result = command.Parse("sdk dump Aspire.Hosting.Redis@");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.InvalidCommand, exitCode);
}
[Fact]
public async Task SdkDumpWithInvalidVersionFormatReturnsInvalidCommand()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
var result = command.Parse("sdk dump Aspire.Hosting.Redis@not-a-version!!!");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.InvalidCommand, exitCode);
}
[Fact]
public async Task SdkDumpWithInvalidArgumentFormatReturnsInvalidCommand()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
var result = command.Parse("sdk dump some-random-string");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.InvalidCommand, exitCode);
}
[Fact]
public void ParsesValidPackageFormatWithoutErrors()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
var result = command.Parse("sdk dump Aspire.Hosting.Redis@13.2.0");
Assert.Empty(result.Errors);
}
[Fact]
public void ParsesMultipleMixedArgumentsWithoutErrors()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
var result = command.Parse("sdk dump Aspire.Hosting.Redis@13.2.0 Aspire.Hosting.PostgreSQL@13.2.0");
Assert.Empty(result.Errors);
}
[Fact]
public void ParsesOutputDirectoryOptionWithoutErrors()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
var result = command.Parse("sdk dump --format ci --output-directory ./ats Aspire.Hosting.Redis@13.2.0");
Assert.Empty(result.Errors);
}
[Fact]
public async Task SdkDumpWithOutputAndOutputDirectoryReturnsInvalidCommand()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
var result = command.Parse("sdk dump --format ci --output ./all.ats.txt --output-directory ./ats Aspire.Hosting.Redis@13.2.0");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.InvalidCommand, exitCode);
}
[Fact]
public async Task SdkDumpWithOutputDirectoryWithoutCiFormatReturnsInvalidCommand()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
var result = command.Parse("sdk dump --output-directory ./ats Aspire.Hosting.Redis@13.2.0");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.InvalidCommand, exitCode);
}
[Fact]
public async Task SdkDumpWithOutputDirectoryWithoutIntegrationsReturnsInvalidCommand()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
var result = command.Parse("sdk dump --format ci --output-directory ./ats");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.InvalidCommand, exitCode);
}
[Fact]
public void ParsesPreReleaseVersionWithoutErrors()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
var result = command.Parse("sdk dump Aspire.Hosting.Redis@13.2.0-preview.1");
Assert.Empty(result.Errors);
}
[Fact]
public async Task SdkDumpWithDoubleAtSignReturnsInvalidCommand()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
// LastIndexOf('@') splits as "Aspire.Hosting.Redis@" and "13.2.0"
// The @ in the package name part triggers format validation
var result = command.Parse("sdk dump Aspire.Hosting.Redis@@13.2.0");
var exitCode = await result.InvokeAsync().DefaultTimeout();
// "Aspire.Hosting.Redis@" is not a valid semver, so it should fail version validation
Assert.Equal(CliExitCodes.InvalidCommand, exitCode);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task SdkDump_PassesRepositoryRootAsScannerRestoreConfigDirectory(bool outputDirectory)
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var repoRoot = AspireRepositoryDetector.DetectRepositoryRoot(workspace.Path);
string? scannerPath = null;
string[]? scannerFiles = null;
var factory = new TestAppHostServerProjectFactory
{
CreateAsyncCallback = (appPath, cancellationToken) =>
{
scannerPath = appPath;
scannerFiles = Directory.GetFiles(appPath);
// Fail preparation after capturing the factory arguments to avoid a real build or RPC session.
// The command is therefore expected to return FailedToBuildArtifacts.
return Task.FromResult<IAppHostServerProject>(new FakeFailingAppHostServerProject(appPath));
}
};
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
services.AddSingleton<IAppHostServerProjectFactory>(factory);
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
var outputOption = outputDirectory ? "--output-directory" : "--output";
var result = command.Parse(["sdk", "dump", "--format", "ci", outputOption,
Path.Combine(workspace.Path, "output"), "Aspire.Hosting.Redis@13.2.0"]);
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.FailedToBuildArtifacts, exitCode);
Assert.NotNull(scannerPath);
Assert.Equal(repoRoot, factory.RestoreRootConfigDirectory);
Assert.NotNull(scannerFiles);
Assert.Empty(scannerFiles);
Assert.False(Directory.Exists(scannerPath));
}
[Fact]
public void SdkDumpCi_ForHostingProject_DoesNotEmitWarnings()
{
// Assertions and skips inside the callback are surfaced back to the parent test process.
using var result = RemoteExecutor.Invoke((Func<string, Task>)(async (baseDirectory) =>
{
var repoRoot = TryFindRepoRoot(baseDirectory);
if (repoRoot is null)
{
Assert.Skip($"Could not find Aspire.slnx starting from '{baseDirectory}'.");
return;
}
var projectPath = Path.Combine(repoRoot, "src", "Aspire.Hosting", "Aspire.Hosting.csproj");
Assert.True(File.Exists(projectPath), $"Could not find Aspire.Hosting project at '{projectPath}'.");
var tempDirectory = Path.Combine(Path.GetTempPath(), "aspire-sdk-dump-tests", $"{Guid.NewGuid():N}");
Directory.CreateDirectory(tempDirectory);
var outputPath = Path.Combine(tempDirectory, "capabilities.txt");
try
{
Environment.CurrentDirectory = repoRoot;
Environment.SetEnvironmentVariable("ASPIRE_REPO_ROOT", repoRoot);
Environment.SetEnvironmentVariable(CliConfigNames.NoLogo, "true");
Environment.SetEnvironmentVariable("DOTNET_CLI_TELEMETRY_OPTOUT", "true");
Environment.SetEnvironmentVariable("DOTNET_SKIP_FIRST_TIME_EXPERIENCE", "true");
Environment.SetEnvironmentVariable("DOTNET_GENERATE_ASPNET_CERTIFICATE", "false");
using var standardOutput = new StringWriter();
using var standardError = new StringWriter();
var originalOutput = Console.Out;
var originalError = Console.Error;
int exitCode;
Console.SetOut(standardOutput);
Console.SetError(standardError);
try
{
// ExtraLongTimeout because this spawns a real dotnet build of Aspire.Hosting.csproj
// in a child process, which can exceed the default timeout under concurrent test load.
exitCode = await Program.Main(["sdk", "dump", "--format", "ci", "--output", outputPath, projectPath]).DefaultTimeout(TestConstants.ExtraLongTimeoutTimeSpan);
}
finally
{
Console.SetOut(originalOutput);
Console.SetError(originalError);
// Forward both streams to the parent test even when the command throws or times out.
Console.WriteLine($"SDK dump stdout:{Environment.NewLine}{standardOutput}");
Console.WriteLine($"SDK dump stderr:{Environment.NewLine}{standardError}");
}
Assert.True(exitCode == CliExitCodes.Success,
$"aspire sdk dump --format ci failed for '{projectPath}'. Expected exit code {CliExitCodes.Success}, actual {exitCode}.{Environment.NewLine}" +
$"stdout:{Environment.NewLine}{standardOutput}{Environment.NewLine}stderr:{Environment.NewLine}{standardError}");
var output = await File.ReadAllTextAsync(outputPath);
Assert.NotEmpty(output);
var warningLines = output
.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries)
.Where(line => line.Contains("warning:", StringComparison.OrdinalIgnoreCase))
.ToArray();
Console.WriteLine($"Warnings: {warningLines.Length}");
foreach (var warningLine in warningLines)
{
Console.WriteLine(warningLine);
}
Assert.Empty(warningLines);
}
finally
{
if (Directory.Exists(tempDirectory))
{
Directory.Delete(tempDirectory, recursive: true);
}
}
}), AppContext.BaseDirectory, options: s_remoteInvokeOptions);
outputHelper.WriteLine(result.Process.StandardOutput.ReadToEnd());
}
[Fact]
public void FormatJson_IncludesExportedValues()
{
var capabilities = CreateCapabilitiesInfo();
var json = InvokeFormatter("FormatJson", capabilities);
using var document = JsonDocument.Parse(json);
var exportedValues = document.RootElement.GetProperty("ExportedValues");
Assert.Single(exportedValues.EnumerateArray());
var exportedValue = exportedValues[0];
Assert.Equal("TestCatalog", exportedValue.GetProperty("PathSegments")[0].GetString());
Assert.Equal("Default", exportedValue.GetProperty("PathSegments")[1].GetString());
Assert.Equal("你好", exportedValue.GetProperty("Value").GetString());
Assert.True(exportedValue.GetProperty("Type").GetProperty("IsNullable").GetBoolean());
}
[Fact]
public void FormatCi_IncludesExportedValues()
{
var capabilities = CreateCapabilitiesInfo();
var output = InvokeFormatter("FormatCi", capabilities);
Assert.Contains("# Exported Values", output);
Assert.Contains("TestCatalog.Default: test/string = \"你好\"", output);
}
[Fact]
public void FormatPretty_IncludesExportedValues()
{
var capabilities = CreateCapabilitiesInfo();
var output = InvokeFormatter("FormatPretty", capabilities);
Assert.Contains("Exported Values (copied into guest SDKs)", output);
Assert.Contains("TestCatalog.Default: string", output);
Assert.Contains("\"你好\"", output);
}
private static string InvokeFormatter(string methodName, CapabilitiesInfo capabilities)
{
var method = typeof(SdkDumpCommand).GetMethod(methodName, BindingFlags.Static | BindingFlags.NonPublic);
Assert.NotNull(method);
return Assert.IsType<string>(method.Invoke(null, [capabilities]));
}
private static CapabilitiesInfo CreateCapabilitiesInfo()
{
return new CapabilitiesInfo
{
ExportedValues =
[
new ExportedValueInfo
{
PathSegments = ["TestCatalog", "Default"],
Type = new TypeRefInfo
{
TypeId = "test/string",
Category = "Primitive",
IsNullable = true
},
Value = JsonValue.Create("你好"),
Description = "Greeting"
}
]
};
}
private static string? TryFindRepoRoot(string startPath)
{
var currentDirectory = new DirectoryInfo(startPath);
while (currentDirectory is not null)
{
if (File.Exists(Path.Combine(currentDirectory.FullName, "Aspire.slnx")))
{
return currentDirectory.FullName;
}
currentDirectory = currentDirectory.Parent;
}
return null;
}
}