File: Commands\NewCommandLocalTemplateTests.PromptDefaults.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.Nodes;
using Aspire.Cli.Tests.TestServices;
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("Demo", "Demo")]
    [InlineData("Demo.App", "Demo-App")]
    [InlineData("Demo App", "Demo-App")]
    [InlineData("...___", "service")]
    [InlineData(".Demo. App-", "Demo-App")]
    public async Task LocalTemplate_ProjectDerivedDefaultsAreDisplayedAndAccepted(string name, string shortName)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        await CreatePromptDefaultsTemplateAsync(workspace);
        // An empty response queue accepts the binding's default, as Enter does in the terminal.
        var interaction = new TestInteractionService();
        using var provider = CreateServices(workspace, interaction, interactive: true).BuildServiceProvider();
 
        var exitCode = await provider.GetRequiredService<RootCommand>()
            .Parse($"new --template-path template --name \"{name}\" --output out --suppress-agent-init")
            .InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.Empty(interaction.DisplayedErrors);
        Assert.Collection(interaction.StringPromptCalls,
            prompt =>
            {
                Assert.Equal("Display name", prompt.PromptText);
                Assert.Equal(name, prompt.DefaultValue);
                Assert.False(prompt.IsSecret);
            },
            prompt =>
            {
                Assert.Equal("Short name", prompt.PromptText);
                Assert.Equal(shortName, prompt.DefaultValue);
            });
        await Verify(ReadOutput(workspace)).UseParameters(name, shortName);
    }
 
    [Theory]
    [InlineData(true)]
    [InlineData(false)]
    public async Task LocalTemplate_ProjectDerivedDefaultsPreserveOverridesAndNonInteractiveFallbacks(bool interactive)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        await CreatePromptDefaultsTemplateAsync(workspace);
        var interaction = new TestInteractionService();
        interaction.SetupStringPromptResponse("Custom display");
        interaction.SetupStringPromptResponse("custom-short");
        using var provider = CreateServices(workspace, interaction, interactive).BuildServiceProvider();
 
        var exitCode = await provider.GetRequiredService<RootCommand>()
            .Parse("new --template-path template --name Demo.App --output out --suppress-agent-init")
            .InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.Empty(interaction.DisplayedErrors);
        Assert.Equal(interactive ? ["Demo.App", "Demo-App"] : [], interaction.StringPromptCalls.Select(prompt => prompt.DefaultValue));
        await Verify(ReadOutput(workspace)).UseParameters(interactive);
    }
 
    [Theory]
    [InlineData(true)]
    [InlineData(false)]
    public async Task LocalTemplate_ProjectDerivedDefaultsUsePromptedNameAndPreserveOmission(bool typeDefaults)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        await CreatePromptDefaultsTemplateAsync(workspace);
        var interaction = new TestInteractionService();
        interaction.SetupStringPromptResponse("Typed.Project Name");
        if (typeDefaults)
        {
            interaction.SetupStringPromptResponse("Typed.Project Name");
            interaction.SetupStringPromptResponse("Typed-Project-Name");
        }
        using var provider = CreateServices(workspace, interaction, interactive: true).BuildServiceProvider();
 
        var exitCode = await provider.GetRequiredService<RootCommand>()
            .Parse("new --template-path template --output out --suppress-agent-init")
            .InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.Empty(interaction.DisplayedErrors);
        Assert.Equal(3, interaction.StringPromptCalls.Count);
        Assert.Equal("Typed.Project Name", interaction.StringPromptCalls[1].DefaultValue);
        Assert.Equal("Typed-Project-Name", interaction.StringPromptCalls[2].DefaultValue);
        await Verify(ReadOutput(workspace)).UseParameters(typeDefaults);
    }
 
    [Theory]
    [InlineData("unknown")]
    [InlineData("random")]
    [InlineData("bound")]
    [InlineData("other-parameter")]
    [InlineData("cycle")]
    [InlineData("required")]
    [InlineData("static-default")]
    [InlineData("wrong-source")]
    [InlineData("wrong-type")]
    [InlineData("invalid-metadata")]
    [InlineData("missing-dependency")]
    [InlineData("no-coalesce")]
    [InlineData("invalid-regex")]
    [InlineData("value-source")]
    [InlineData("nested-missing-source")]
    [InlineData("nested-missing-fallback")]
    [InlineData("nested-name-source")]
    [InlineData("regex-omitted-override")]
    [InlineData("coalesce-omitted-fallback")]
    [InlineData("redefined-name-constant")]
    [InlineData("redefined-name-random")]
    [InlineData("redefined-name-parameter")]
    public async Task LocalTemplate_InvalidProjectDerivedDefaultsFailBeforePromptingOrWriting(string scenario)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        await CreatePromptDefaultsTemplateAsync(workspace);
        var templatePath = Path.Combine(workspace.Path, "template", ".template.config", "template.json");
        var template = JsonNode.Parse(await File.ReadAllTextAsync(templatePath))!;
        var symbols = template["symbols"]!;
        var hostPath = Path.Combine(workspace.Path, "template", ".template.config", "aspirecli.host.json");
        var host = JsonNode.Parse(await File.ReadAllTextAsync(hostPath))!;
        switch (scenario)
        {
            case "unknown":
                host["symbolInfo"]!["DisplayName"]!["defaultValueSource"] = "missing";
                break;
            case "random":
                symbols["normalizedName"] = JsonNode.Parse("""{"type":"generated","generator":"random","parameters":{"low":1,"high":10}}""");
                break;
            case "bound":
                symbols["normalizedName"] = JsonNode.Parse("""{"type":"bind","binding":"env:HOME"}""");
                break;
            case "other-parameter":
                symbols["normalizedName"]!["parameters"]!["source"] = "DisplayName";
                break;
            case "cycle":
                symbols["normalizedName"]!["parameters"]!["source"] = "shortNameValue";
                break;
            case "required":
                symbols["DisplayName"]!["isRequired"] = true;
                break;
            case "static-default":
                symbols["DisplayName"]!["defaultValue"] = "Static";
                break;
            case "wrong-source":
                symbols["displayNameValue"]!["parameters"]!["sourceVariableName"] = "ShortName";
                break;
            case "wrong-type":
                symbols["DisplayName"]!["datatype"] = "bool";
                break;
            case "invalid-metadata":
                host["symbolInfo"]!["DisplayName"]!["defaultValueSource"] = true;
                break;
            case "missing-dependency":
                symbols["normalizedName"]!["parameters"]!["source"] = "missing";
                break;
            case "no-coalesce":
                host["symbolInfo"]!["ShortName"]!["defaultValueSource"] = "normalizedName";
                break;
            case "invalid-regex":
                symbols["normalizedName"]!["parameters"]!["steps"]![0]!["regex"] = "[";
                break;
            case "value-source":
                host["symbolInfo"]!["DisplayName"]!["valueSource"] = JsonNode.Parse(
                    """{"kind":"nugetPackageVersion","packageId":"Example.Package","versionRange":"[1.0,2.0)"}""");
                break;
            case "nested-missing-source":
                symbols["nestedName"] = JsonNode.Parse(
                    """{"type":"generated","generator":"coalesce","parameters":{"sourceVariableName":"missing","fallbackVariableName":"name"}}""");
                symbols["shortNameValue"]!["parameters"]!["fallbackVariableName"] = "nestedName";
                break;
            case "nested-missing-fallback":
                symbols["nestedName"] = JsonNode.Parse(
                    """{"type":"generated","generator":"coalesce","parameters":{"sourceVariableName":"normalizedName","fallbackVariableName":"missing"}}""");
                symbols["shortNameValue"]!["parameters"]!["fallbackVariableName"] = "nestedName";
                break;
            case "nested-name-source":
                symbols["nestedName"] = JsonNode.Parse(
                    """{"type":"generated","generator":"coalesce","parameters":{"sourceVariableName":"name","fallbackVariableName":"normalizedName"}}""");
                symbols["shortNameValue"]!["parameters"]!["fallbackVariableName"] = "nestedName";
                break;
            case "regex-omitted-override":
                symbols["normalizedName"]!["parameters"]!["source"] = "ShortName";
                break;
            case "coalesce-omitted-fallback":
                symbols["nestedName"] = JsonNode.Parse(
                    """{"type":"generated","generator":"coalesce","parameters":{"sourceVariableName":"normalizedName","fallbackVariableName":"ShortName"}}""");
                symbols["shortNameValue"]!["parameters"]!["fallbackVariableName"] = "nestedName";
                break;
            case "redefined-name-constant":
                symbols["name"] = JsonNode.Parse(
                    """{"type":"generated","generator":"constant","parameters":{"value":"DifferentName"}}""");
                break;
            case "redefined-name-random":
                symbols["name"] = JsonNode.Parse(
                    """{"type":"generated","generator":"random","parameters":{"low":1,"high":10}}""");
                break;
            case "redefined-name-parameter":
                symbols["name"] = JsonNode.Parse(
                    """{"type":"parameter","datatype":"string","defaultValue":"DifferentName"}""");
                break;
        }
        await File.WriteAllTextAsync(templatePath, template.ToJsonString());
        await File.WriteAllTextAsync(hostPath, host.ToJsonString());
        var interaction = new TestInteractionService();
        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.NotEqual(CliExitCodes.Success, exitCode);
        Assert.NotEmpty(interaction.DisplayedErrors);
        Assert.Empty(interaction.StringPromptCalls);
        Assert.False(Directory.Exists(Path.Combine(workspace.Path, "out")));
    }
 
    [Theory]
    [InlineData(false, false)]
    [InlineData(false, true)]
    [InlineData(true, false)]
    [InlineData(true, true)]
    public async Task LocalTemplate_InvalidProjectDerivedDefaultsFailBeforeProjectPrompts(bool supplyName, bool supplyOutput)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        await CreatePromptDefaultsTemplateAsync(workspace);
        var hostPath = Path.Combine(workspace.Path, "template", ".template.config", "aspirecli.host.json");
        await File.WriteAllTextAsync(hostPath, """
            { "symbolInfo": { "ShortName": { "defaultValueSource": "missing" } } }
            """);
        var interaction = new TestInteractionService();
        using var provider = CreateServices(workspace, interaction, interactive: true).BuildServiceProvider();
        var options = $"{(supplyName ? "--name Demo" : "")} {(supplyOutput ? "--output out" : "")}";
 
        var exitCode = await provider.GetRequiredService<RootCommand>()
            .Parse($"new --template-path template {options} --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")));
    }
 
    [Fact]
    public async Task LocalTemplate_ProjectDerivedDefaultsSupportNestedCoalesce()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        await CreatePromptDefaultsTemplateAsync(workspace);
        var templatePath = Path.Combine(workspace.Path, "template", ".template.config", "template.json");
        var template = JsonNode.Parse(await File.ReadAllTextAsync(templatePath))!;
        var symbols = template["symbols"]!;
        symbols["nestedName"] = JsonNode.Parse(
            """{"type":"generated","generator":"coalesce","parameters":{"sourceVariableName":"normalizedName","fallbackVariableName":"name"}}""");
        symbols["shortNameValue"]!["parameters"]!["fallbackVariableName"] = "nestedName";
        await File.WriteAllTextAsync(templatePath, template.ToJsonString());
        var interaction = new TestInteractionService();
        using var provider = CreateServices(workspace, interaction, interactive: true).BuildServiceProvider();
 
        var exitCode = await provider.GetRequiredService<RootCommand>()
            .Parse("new --template-path template --name Demo.App --output out --suppress-agent-init")
            .InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.Empty(interaction.DisplayedErrors);
        Assert.Equal(["Demo.App", "Demo-App"], interaction.StringPromptCalls.Select(prompt => prompt.DefaultValue));
        await Verify(ReadOutput(workspace));
    }
 
    private static async Task CreatePromptDefaultsTemplateAsync(TemporaryWorkspace workspace)
    {
        var config = workspace.CreateDirectory("template/.template.config");
        await File.WriteAllTextAsync(Path.Combine(config.FullName, "template.json"), """
            {
              "identity": "Aspire.Local.PromptDefaults", "name": "Prompt defaults", "shortName": "prompt-defaults",
              "symbols": {
                "DisplayName": {
                  "type": "parameter", "datatype": "string", "defaultValue": "",
                  "displayName": "Display name", "replaces": "RAW_DISPLAY"
                },
                "ShortName": {
                  "type": "parameter", "datatype": "string", "defaultValue": "",
                  "displayName": "Short name", "replaces": "RAW_SHORT"
                },
                "displayNameValue": {
                  "type": "generated", "generator": "coalesce",
                  "parameters": { "sourceVariableName": "DisplayName", "fallbackVariableName": "name" },
                  "replaces": "DISPLAY_VALUE"
                },
                "normalizedName": {
                  "type": "generated", "generator": "regex",
                  "parameters": {
                    "source": "name",
                    "steps": [
                      { "regex": "[^A-Za-z0-9]+", "replacement": "-" },
                      { "regex": "^-+|-+$", "replacement": "" },
                      { "regex": "^$", "replacement": "service" }
                    ]
                  }
                },
                "shortNameValue": {
                  "type": "generated", "generator": "coalesce",
                  "parameters": { "sourceVariableName": "ShortName", "fallbackVariableName": "normalizedName" },
                  "replaces": "SHORT_VALUE"
                }
              }
            }
            """);
        await File.WriteAllTextAsync(Path.Combine(config.FullName, "aspirecli.host.json"), """
            {
              "symbolInfo": {
                "DisplayName": { "defaultValueSource": "displayNameValue" },
                "ShortName": { "defaultValueSource": "shortNameValue" }
              }
            }
            """);
        await File.WriteAllTextAsync(Path.Combine(workspace.Path, "template", "names.txt"),
            "Display=DISPLAY_VALUE\nShort=SHORT_VALUE\nRawDisplay=RAW_DISPLAY\nRawShort=RAW_SHORT\n");
    }
}