// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Globalization;
using System.Text.Json;
using Aspire.Cli.Packaging;
using Aspire.Cli.Tests.TestServices;
using Aspire.Cli.Tests.Utils;
using Aspire.Cli.Utils;
using Microsoft.AspNetCore.InternalTesting;
using Microsoft.Extensions.DependencyInjection;
using RootCommand = Aspire.Cli.Commands.RootCommand;
namespace Aspire.Cli.Tests.Commands;
public partial class NewCommandLocalTemplateTests
{
[Theory]
[InlineData(0, null, true, true)]
[InlineData(0, "true", false, true)]
[InlineData(0, "\"true\"", false, true)]
[InlineData(0, "false", true, true)]
[InlineData(0, "\"false\"", true, true)]
[InlineData(1, "true", true, true)]
[InlineData(1, "invalid-json", true, true)]
[InlineData(2, "true", true, false)]
public async Task LocalTemplate_HostConfigurationSelectsOneFile(
int aspireConfiguration, string? dotnetHidden, bool promptGreeting, bool promptMode)
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
await CreateTemplateAsync(workspace, "template");
var config = Path.Combine(workspace.Path, "template", ".template.config");
if (dotnetHidden is not null)
{
await File.WriteAllTextAsync(Path.Combine(config, "dotnetcli.host.json"), $$"""
{ "symbolInfo": { "Greeting": { "isHidden": {{dotnetHidden}} } } }
""");
}
if (aspireConfiguration != 0)
{
await File.WriteAllTextAsync(Path.Combine(config, "aspirecli.host.json"), aspireConfiguration == 1 ? "{}" : """
{
"symbolInfo": {
"Greeting": { "displayName": "Message" },
"Mode": { "isHidden": true }
}
}
""");
}
var interaction = new TestInteractionService();
if (promptGreeting)
{
interaction.SetupStringPromptResponse("Welcome");
}
interaction.SetupBooleanResponse(true);
var modePrompts = 0;
interaction.PromptForSelectionCallback = (_, _, _, _) =>
{
modePrompts++;
return "api";
};
interaction.PromptForSelectionsCallback = (_, _, _, _) => ["web"];
using var provider = CreateServices(workspace, interaction, interactive: true).BuildServiceProvider();
var exitCode = await provider.GetRequiredService<RootCommand>()
.Parse("new --template-path template --name Demo --output out --suppress-agent-init")
.InvokeAsync().DefaultTimeout();
Assert.True(exitCode == CliExitCodes.Success, string.Join(Environment.NewLine, interaction.DisplayedErrors));
Assert.Empty(interaction.DisplayedErrors);
Assert.Equal(promptMode ? 1 : 0, modePrompts);
if (promptGreeting)
{
Assert.Equal(aspireConfiguration == 2 ? "Message" : "Greeting text",
Assert.Single(interaction.StringPromptCalls).PromptText);
}
else
{
Assert.Empty(interaction.StringPromptCalls);
}
await Verify(ReadOutput(workspace)).ScrubInlineGuids().UseParameters(aspireConfiguration, dotnetHidden);
}
[Fact]
public async Task LocalTemplate_HiddenParametersRetainEngineDefaultsForEveryPromptType()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
await CreateTemplateAsync(workspace, "template");
await File.WriteAllTextAsync(Path.Combine(workspace.Path, "template", ".template.config", "aspirecli.host.json"), """
{
"symbolInfo": {
"Greeting": { "isHidden": true },
"IncludeMessage": { "isHidden": true },
"Mode": { "isHidden": true },
"Features": { "isHidden": true }
}
}
""");
var interaction = new TestInteractionService();
interaction.PromptForSelectionCallback = (_, _, _, _) => throw new InvalidOperationException("Hidden choices must not prompt.");
interaction.PromptForSelectionsCallback = (_, _, _, _) => throw new InvalidOperationException("Hidden choices must not prompt.");
using var provider = CreateServices(workspace, interaction, interactive: true).BuildServiceProvider();
var exitCode = await provider.GetRequiredService<RootCommand>()
.Parse("new --template-path template --name Demo --output out --suppress-agent-init")
.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
Assert.Empty(interaction.DisplayedErrors);
Assert.Empty(interaction.StringPromptCalls);
Assert.Empty(interaction.BooleanPromptCalls);
await Verify(ReadOutput(workspace)).ScrubInlineGuids();
}
[Theory]
[InlineData("aspirecli.host.json", true)]
[InlineData("aspirecli.host.json", false)]
[InlineData("dotnetcli.host.json", true)]
[InlineData("dotnetcli.host.json", false)]
public async Task LocalTemplate_HiddenPortsUseGeneratedFallbacksAndHiddenControllersUseDefaults(
string hostFile, bool interactive)
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var config = workspace.CreateDirectory("template/.template.config");
await File.WriteAllTextAsync(Path.Combine(config.FullName, "template.json"), """
{
"identity": "Aspire.Local.HiddenPort",
"name": "Hidden port",
"shortName": "hidden-port",
"symbols": {
"Detail": {
"type": "parameter", "datatype": "string", "defaultValue": "Engine default",
"isEnabled": "(IncludeDetails)", "replaces": "DetailValue"
},
"IncludeDetails": { "type": "parameter", "datatype": "bool", "defaultValue": "true" },
"Port": { "type": "parameter", "datatype": "integer" },
"GeneratedPort": {
"type": "generated", "generator": "port", "parameters": { "low": 15000, "high": 15300 }
},
"EffectivePort": {
"type": "generated", "generator": "coalesce", "replaces": "15000",
"parameters": { "sourceVariableName": "Port", "fallbackVariableName": "GeneratedPort" }
}
}
}
""");
await File.WriteAllTextAsync(Path.Combine(config.FullName, hostFile), """
{
"symbolInfo": {
"Port": { "isHidden": true },
"IncludeDetails": { "isHidden": true }
}
}
""");
await File.WriteAllTextAsync(Path.Combine(workspace.Path, "template", "settings.json"),
"""{ "port": 15000, "detail": "DetailValue" }""");
var interaction = new TestInteractionService();
interaction.SetupStringPromptResponse("Updated");
using var provider = CreateServices(workspace, interaction, interactive).BuildServiceProvider();
var exitCode = await provider.GetRequiredService<RootCommand>()
.Parse("new --template-path template --name Demo --output out --suppress-agent-init")
.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
Assert.Empty(interaction.DisplayedErrors);
Assert.Empty(interaction.BooleanPromptCalls);
Assert.Equal(interactive ? 1 : 0, interaction.StringPromptCalls.Count);
using var settings = JsonDocument.Parse(await File.ReadAllTextAsync(Path.Combine(workspace.Path, "out", "settings.json")));
var port = settings.RootElement.GetProperty("port").GetInt32();
Assert.InRange(port, 15000, 15300);
Assert.Equal(interactive ? "Updated" : "Engine default", settings.RootElement.GetProperty("detail").GetString());
await Verify(ReadOutput(workspace))
.AddScrubber(text => text.Replace(port.ToString(CultureInfo.InvariantCulture), "{generated-port}"))
.UseParameters(hostFile, interactive);
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task LocalTemplate_HiddenRequiredParametersAreStillEngineValidated(bool interactive)
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
await CreateTemplateAsync(workspace, "template", requiredGreeting: true);
await File.WriteAllTextAsync(Path.Combine(workspace.Path, "template", ".template.config", "aspirecli.host.json"), """
{ "symbolInfo": { "Greeting": { "isHidden": true } } }
""");
var interaction = new TestInteractionService();
interaction.SetupBooleanResponse(true);
interaction.PromptForSelectionCallback = (_, _, _, _) => "api";
interaction.PromptForSelectionsCallback = (_, _, _, _) => ["web"];
using var provider = CreateServices(workspace, interaction, interactive).BuildServiceProvider();
var exitCode = await provider.GetRequiredService<RootCommand>()
.Parse("new --template-path template --name Demo --output out --suppress-agent-init")
.InvokeAsync().DefaultTimeout();
Assert.NotEqual(CliExitCodes.Success, exitCode);
Assert.NotEmpty(interaction.DisplayedErrors);
Assert.Empty(interaction.StringPromptCalls);
Assert.False(Directory.Exists(Path.Combine(workspace.Path, "out")));
}
[Theory]
[InlineData("{")]
[InlineData("[]")]
[InlineData("""{ "symbolInfo": [] }""")]
[InlineData("""{ "symbolInfo": { "Greeting": true } }""")]
[InlineData("""{ "symbolInfo": { "Greeting": { "isHidden": 1 } } }""")]
[InlineData("""{ "symbolInfo": { "Greeting": { "isHidden": "yes" } } }""")]
[InlineData("""{ "symbolInfo": { "Greeting": { "displayName": false } } }""")]
[InlineData("""{ "createNuGetConfig": "true" }""")]
[InlineData("""{ "createNuGetConfig": null }""")]
[InlineData("""{ "createNuGetConfig": 1 }""")]
[InlineData("""{ "createNuGetConfig": [] }""")]
[InlineData("""{ "createNuGetConfig": {} }""")]
public async Task LocalTemplate_InvalidSelectedHostConfigurationDoesNotFallBack(string contents)
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
await CreateTemplateAsync(workspace, "template");
var config = Path.Combine(workspace.Path, "template", ".template.config");
await File.WriteAllTextAsync(Path.Combine(config, "aspirecli.host.json"), contents);
await File.WriteAllTextAsync(Path.Combine(config, "dotnetcli.host.json"), "{}");
var interaction = new TestInteractionService();
using var provider = CreateServices(workspace, interaction, interactive: false).BuildServiceProvider();
var exitCode = await provider.GetRequiredService<RootCommand>()
.Parse("new --template-path template --name Demo --output out --suppress-agent-init")
.InvokeAsync().DefaultTimeout();
Assert.NotEqual(CliExitCodes.Success, exitCode);
Assert.NotEmpty(interaction.DisplayedErrors);
Assert.False(Directory.Exists(Path.Combine(workspace.Path, "out")));
}
[Fact]
public async Task LocalTemplate_HiddenVersionStillUsesCliIdentity()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
await CreateVersionedTemplateAsync(workspace);
await File.WriteAllTextAsync(Path.Combine(workspace.Path, "template", ".template.config", "aspirecli.host.json"), """
{ "symbolInfo": { "AspireVersion": { "isHidden": true } } }
""");
var interaction = new TestInteractionService();
var services = CreateServices(workspace, interaction, interactive: true);
services.AddSingleton(workspace.CreateExecutionContext(identityChannel: "stable", identityVersion: "17.0.0"));
using var provider = services.BuildServiceProvider();
var exitCode = await provider.GetRequiredService<RootCommand>()
.Parse("new --template-path template --name Demo --output out --suppress-agent-init")
.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
Assert.Empty(interaction.DisplayedErrors);
Assert.Empty(interaction.StringPromptCalls);
await Verify(ReadOutput(workspace));
}
[Fact]
public async Task LocalTemplate_HiddenNuGetSourceStillUsesCliIdentity()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
await CreateNuGetSourceTemplateAsync(workspace);
await File.WriteAllTextAsync(Path.Combine(workspace.Path, "template", ".template.config", "aspirecli.host.json"), """
{
"symbolInfo": {
"AspireVersion": { "isHidden": true },
"AspireNuGetSource": { "isHidden": true }
}
}
""");
var interaction = new TestInteractionService();
var services = CreateServices(workspace, interaction, interactive: true);
services.AddSingleton(workspace.CreateExecutionContext(identityChannel: "stable", identityVersion: "17.0.0"));
using var provider = services.BuildServiceProvider();
var exitCode = await provider.GetRequiredService<RootCommand>()
.Parse("new --template-path template --name Demo --output out --suppress-agent-init")
.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
Assert.Empty(interaction.DisplayedErrors);
Assert.Empty(interaction.StringPromptCalls);
await Verify(ReadOutput(workspace));
}
[Theory]
[InlineData("absent")]
[InlineData("empty")]
[InlineData("false")]
public async Task LocalTemplate_PolyglotVersionDoesNotRequestNuGetPolicy(string hostConfiguration)
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
await CreateVersionedTemplateAsync(workspace);
var hostFile = Path.Combine(workspace.Path, "template", ".template.config", "aspirecli.host.json");
if (hostConfiguration == "absent")
{
File.Delete(hostFile);
}
else
{
await File.WriteAllTextAsync(hostFile,
hostConfiguration == "empty" ? "{}" : """{ "createNuGetConfig": false }""");
}
await File.WriteAllTextAsync(Path.Combine(workspace.Path, "template", "aspire.config.json"), """
{
"appHost": { "path": "apphost.mts", "language": "typescript/nodejs" },
"sdk": { "version": "AspireVersionValue" },
"packages": { "Aspire.Hosting.JavaScript": "AspireVersionValue" }
}
""");
await File.WriteAllTextAsync(Path.Combine(workspace.Path, "template", "apphost.mts"),
"""import { createBuilder } from "./.aspire/sdk/aspire.js";""");
var interaction = new TestInteractionService();
var services = CreateServices(workspace, interaction, interactive: false);
services.AddSingleton(workspace.CreateExecutionContext(identityChannel: "pr-missing", identityVersion: "17.0.0-pr.12345"));
services.AddSingleton<IPackagingService>(new TestPackagingService());
using var provider = services.BuildServiceProvider();
var exitCode = await provider.GetRequiredService<RootCommand>()
.Parse("new --template-path template --name Demo --output out --suppress-agent-init")
.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
Assert.Empty(interaction.DisplayedErrors);
await Verify(ReadOutput(workspace)).UseParameters(hostConfiguration);
}
[Theory]
[InlineData(null, true, true)]
[InlineData("{}", true, false)]
[InlineData("""{ "createNuGetConfig": false }""", true, false)]
[InlineData("""{ "createNuGetConfig": true }""", false, true)]
public async Task LocalTemplate_NuGetConfigOptInUsesOneHostFileWithoutRequiringVersionParameter(
string? aspireConfiguration, bool dotnetOptIn, bool expectedCreate)
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
await CreateVersionedTemplateAsync(workspace);
var config = Path.Combine(workspace.Path, "template", ".template.config");
await File.WriteAllTextAsync(Path.Combine(config, "template.json"), """
{
"identity": "Aspire.Local.NuGetOptIn",
"name": "NuGet opt-in template",
"shortName": "nuget-opt-in"
}
""");
await File.WriteAllTextAsync(Path.Combine(workspace.Path, "template", "aspire.config.json"), "{}");
var hostFile = Path.Combine(config, "aspirecli.host.json");
if (aspireConfiguration is null)
{
File.Delete(hostFile);
}
else
{
await File.WriteAllTextAsync(hostFile, aspireConfiguration);
}
await File.WriteAllTextAsync(Path.Combine(config, "dotnetcli.host.json"),
JsonSerializer.Serialize(new { createNuGetConfig = dotnetOptIn }));
var interaction = new TestInteractionService();
var services = CreateServices(workspace, interaction, interactive: false);
services.AddSingleton(workspace.CreateExecutionContext(identityChannel: "daily", identityVersion: "17.0.0-preview.1"));
using var provider = services.BuildServiceProvider();
var exitCode = await provider.GetRequiredService<RootCommand>()
.Parse("new --template-path template --name Demo --output out --suppress-agent-init")
.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
Assert.Empty(interaction.DisplayedErrors);
var output = Path.Combine(workspace.Path, "out");
Assert.Equal(expectedCreate, File.Exists(Path.Combine(output, "nuget.config")));
Assert.Equal(
expectedCreate ? "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet9/nuget/v3/index.json" : "https://api.nuget.org/v3/index.json",
Assert.Single(NuGetTestHelper.GetEligiblePackageSources(output, "Aspire.Hosting.Redis")));
}
public static TheoryData<string, string, string[], string[], string?, string> BuiltInTemplatePrompts => new()
{
{ "aspire-apphost", "None", [], [], "17.0", "17.0.0" },
{ "aspire-apphost-singlefile", "None", [], ["Use .dev.localhost URLs"], "17.0", "17.0.0" },
{ "aspire-empty", "None", [], [], "17.0", "17.0.0" },
{ "aspire-empty", "None", [], [], "17.0", "17.0.0-preview.1" },
{ "aspire-empty", "None", [], [], "17.0", "17.0.0-dev" },
{ "aspire-empty", "None", [], [], null, "17.0.0" },
{ "aspire-empty", "None", [], [], null, "17.0.0-preview.1" },
{ "aspire-empty", "None", [], [], null, "17.0.0-dev" },
{ "aspire-starter", "None", ["Test framework"], ["Use Redis for caching", "Use .dev.localhost URLs"], "17.0", "17.0.0" },
{ "aspire-starter", "xUnit.net", ["Test framework", "xUnit.net version"], ["Use Redis for caching", "Use .dev.localhost URLs"], "17.0", "17.0.0" },
{ "aspire-ts-cs-starter", "None", [], ["Use Redis for caching", "Use .dev.localhost URLs"], "17.0", "17.0.0" }
};
[Theory]
[MemberData(nameof(BuiltInTemplatePrompts))]
public async Task LocalTemplate_BuiltInTemplatesHaveCuratedPromptsAndGeneratedPorts(
string templateName, string testFramework, string[] expectedChoices, string[] expectedBooleans,
string? packagedVersion, string identityVersion)
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var source = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory,
"..", "..", "..", "..", "..", "src", "Aspire.ProjectTemplates", "templates", templateName));
var template = workspace.CreateDirectory("template");
FileSystemHelper.CopyDirectory(source, template.FullName);
if (packagedVersion is not null)
{
var configurationPath = Path.Combine(template.FullName, ".template.config", "template.json");
// Packaging replaces this metadata with major.minor, not the full SDK version.
// Keep the unmodified source template covered as well.
await File.WriteAllTextAsync(configurationPath,
(await File.ReadAllTextAsync(configurationPath))
.Replace("!!REPLACE_WITH_LATEST_MAJOR_MINOR_VERSION!!", packagedVersion, StringComparison.Ordinal));
}
var interaction = new TestInteractionService();
foreach (var _ in expectedBooleans)
{
interaction.SetupBooleanResponse(false);
}
var choicePrompts = new List<string>();
interaction.PromptForSelectionCallback = (prompt, choices, _, _) =>
{
choicePrompts.Add(prompt);
return prompt == "Test framework" ? testFramework : choices.Cast<string>().First();
};
var services = CreateServices(workspace, interaction, interactive: true);
services.AddSingleton(workspace.CreateExecutionContext(identityChannel: "stable", identityVersion: identityVersion));
using var provider = services.BuildServiceProvider();
var exitCode = await provider.GetRequiredService<RootCommand>()
.Parse("new --template-path template --name Demo --output out --suppress-agent-init")
.InvokeAsync().DefaultTimeout();
Assert.True(exitCode == CliExitCodes.Success, string.Join(Environment.NewLine, interaction.DisplayedErrors));
Assert.Empty(interaction.DisplayedErrors);
Assert.Empty(interaction.StringPromptCalls);
Assert.Equal(expectedChoices, choicePrompts);
Assert.Equal(expectedBooleans, interaction.BooleanPromptCalls.Select(call => call.PromptText));
var output = Path.Combine(workspace.Path, "out");
var profiles = Directory.GetFiles(output, "launchSettings.json", SearchOption.AllDirectories)
.Concat(Directory.GetFiles(output, "apphost.run.json", SearchOption.AllDirectories)).ToArray();
Assert.Equal(templateName switch
{
"aspire-starter" => 3,
"aspire-ts-cs-starter" => 2,
"aspire-apphost" or "aspire-apphost-singlefile" or "aspire-empty" => 1,
_ => 0
}, profiles.Length);
foreach (var path in profiles)
{
using var document = JsonDocument.Parse(await File.ReadAllTextAsync(path),
new JsonDocumentOptions { CommentHandling = JsonCommentHandling.Skip });
var launchProfiles = document.RootElement.GetProperty("profiles");
Assert.Equal(["http", "https"], launchProfiles.EnumerateObject().Select(profile => profile.Name).Order(StringComparer.Ordinal));
foreach (var profile in launchProfiles.EnumerateObject())
{
foreach (var url in profile.Value.GetProperty("applicationUrl").GetString()!.Split(';'))
{
Assert.InRange(new Uri(url).Port, 1024, 65535);
}
foreach (var variable in profile.Value.GetProperty("environmentVariables").EnumerateObject())
{
if (variable.Name.EndsWith("_URL", StringComparison.Ordinal))
{
Assert.InRange(new Uri(variable.Value.GetString()!).Port, 1024, 65535);
}
}
}
}
}
}