// 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.Xml.Linq;
using Aspire.Cli.Agents;
using Aspire.Cli.Utils;
using Aspire.Cli.Commands;
using RootCommand = Aspire.Cli.Commands.RootCommand;
using Aspire.Cli.Configuration;
using Aspire.Cli.Interaction;
using Aspire.Cli.NuGet;
using Aspire.Cli.Packaging;
using Aspire.Cli.Projects;
using Aspire.Cli.Resources;
using Aspire.Cli.Scaffolding;
using Aspire.Cli.Templating;
using Aspire.Cli.Tests.TestServices;
using Aspire.Cli.Tests.Utils;
using Microsoft.AspNetCore.InternalTesting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using NuGetPackage = Aspire.Shared.NuGetPackageCli;
namespace Aspire.Cli.Tests.Commands;
[Collection(LocalizedResourceMutatingTestCollection.Name)]
public class NewCommandTests(ITestOutputHelper outputHelper)
{
[Fact]
public async Task NewCommandWithHelpArgumentReturnsZero()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var services = CreateServiceCollection(workspace);
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
var result = command.Parse("new --help");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
}
[Fact]
public void NewCommandWithPolyglotEnabled_ExposesTemplateSubcommands()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var services = CreateServiceCollection(workspace, options =>
{
options.FeatureFlagsFactory = _ =>
{
var features = new TestFeatures();
features.SetFeature(KnownFeatures.ExperimentalPolyglotGo, true);
features.SetFeature(KnownFeatures.ExperimentalPolyglotJava, true);
features.SetFeature(KnownFeatures.ExperimentalPolyglotPython, true);
features.SetFeature(KnownFeatures.ExperimentalPolyglotRust, true);
return features;
};
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<NewCommand>();
Assert.NotEmpty(command.Subcommands);
Assert.Contains(command.Subcommands, subcommand => subcommand.Name == KnownTemplateId.CSharpEmptyAppHost && subcommand.Description == "Empty AppHost (Choose language...)");
Assert.Contains(command.Subcommands, subcommand => subcommand.Name == KnownTemplateId.TypeScriptEmptyAppHost && subcommand.Description == "Empty (TypeScript AppHost)");
Assert.Contains(command.Subcommands, subcommand => subcommand.Name == KnownTemplateId.PythonEmptyAppHost && subcommand.Description == "Empty (Python AppHost)");
Assert.Contains(command.Subcommands, subcommand => subcommand.Name == KnownTemplateId.JavaEmptyAppHost && subcommand.Description == "Empty (Java AppHost)");
Assert.Contains(command.Subcommands, subcommand => subcommand.Name == KnownTemplateId.GoEmptyAppHost && subcommand.Description == "Empty (Go AppHost)");
Assert.Contains(command.Subcommands, subcommand => subcommand.Name == KnownTemplateId.RustEmptyAppHost && subcommand.Description == "Empty (Rust AppHost)");
}
[Fact]
public void NewCommandWithPolyglotDisabled_ExposesTemplateSubcommands()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var services = CreateServiceCollection(workspace);
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<NewCommand>();
Assert.NotEmpty(command.Subcommands);
Assert.DoesNotContain(command.Options, option => option.Aliases.Contains("--language", StringComparer.OrdinalIgnoreCase));
}
[Fact]
public void NewCommand_WhenIdentityChannelIsStaging_DescribesStagingChannelOption()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var services = CreateServiceCollection(workspace, options =>
{
options.CliExecutionContextFactory = _ => workspace.CreateExecutionContext(identityChannel: PackageChannelNames.Staging);
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<NewCommand>();
var channelOption = command.Options.Single(option => option.Name == "--channel");
Assert.Equal(NewCommandStrings.ChannelOptionDescriptionWithStaging, channelOption.Description);
}
[Fact]
public async Task NewCommand_CSharpEmptyTemplateUnderStagingIdentity_WritesStagingConfiguration()
{
const string stagingFeed = "https://example.com/staging/v3/index.json";
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var configServices = CreateServiceCollection(workspace);
using (var configProvider = configServices.BuildServiceProvider())
{
var configCommand = configProvider.GetRequiredService<RootCommand>();
var configResult = configCommand.Parse($"config set -g overrideStagingFeed {stagingFeed}");
var configExitCode = await configResult.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, configExitCode);
}
var cache = new FakeNuGetPackageCache
{
GetTemplatePackagesAsyncCallback = (_, _, _, _) =>
Task.FromResult<IEnumerable<NuGetPackage>>(
[
new NuGetPackage
{
Id = TemplateNuGetConfigService.TemplatesPackageName,
Source = stagingFeed,
Version = "13.4.0-preview.1.12345"
}
])
};
var services = CreateServiceCollection(workspace, options =>
{
options.CliExecutionContextFactory = _ => workspace.CreateExecutionContext(identityChannel: PackageChannelNames.Staging);
options.NuGetPackageCacheFactory = _ => cache;
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
var result = command.Parse($"new {KnownTemplateId.CSharpEmptyAppHost} --language csharp --name TemplateOut --output ./TemplateOut --localhost-tld false");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
var outputDirectory = Path.Combine(workspace.WorkspaceRoot.FullName, "TemplateOut");
var nugetConfigPath = Path.Combine(outputDirectory, "nuget.config");
Assert.True(File.Exists(nugetConfigPath));
var nugetConfig = await File.ReadAllTextAsync(nugetConfigPath);
Assert.Contains(stagingFeed, nugetConfig);
Assert.Contains("Aspire*", nugetConfig);
var config = AspireConfigFile.Load(outputDirectory);
Assert.NotNull(config);
Assert.Equal(PackageChannelNames.Staging, config.Channel);
Assert.Equal(VersionHelper.GetDefaultSdkVersion(), config.SdkVersion);
}
[Fact]
public async Task NewCommandInteractiveFlowSmokeTest()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var services = CreateServiceCollection(workspace);
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<NewCommand>();
var result = command.Parse("new aspire-starter --use-redis-cache --test-framework None");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
}
[Theory]
[InlineData("None", null)]
[InlineData("Ninguno", null)]
[InlineData("MSTest", "MSTest")]
public async Task NewCommandForwardsLocalizedTestFrameworkSelection(string testFramework, string? expectedTestFramework)
{
var originalCulture = TemplatingStrings.Culture;
try
{
TemplatingStrings.Culture = CultureInfo.GetCultureInfo("es-ES");
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var runner = CreateTestRunnerWithStandardPackages();
var services = CreateServiceCollection(workspace, options => options.DotNetCliRunnerFactory = _ => runner);
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<NewCommand>();
var result = command.Parse($"new aspire-starter --use-redis-cache --test-framework {testFramework}");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
var extraArgs = Assert.IsType<string[]>(runner.LastNewProjectExtraArgs);
var testFrameworkArgumentIndex = Array.IndexOf(extraArgs, "--test-framework");
var forwardedTestFramework = testFrameworkArgumentIndex >= 0
? extraArgs[testFrameworkArgumentIndex + 1]
: null;
Assert.Equal(expectedTestFramework, forwardedTestFramework);
}
finally
{
TemplatingStrings.Culture = originalCulture;
}
}
[CollectionDefinition(Name, DisableParallelization = true)]
public sealed class LocalizedResourceMutatingTestCollection
{
public const string Name = nameof(LocalizedResourceMutatingTestCollection);
}
[Fact]
// Quarantined due to flakiness. See linked issue for details.
public async Task NewCommandDerivesProjectNameFromTemplateNameForStarterTemplate()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
string? capturedDefaultProjectName = null;
string? capturedDefaultOutputPath = null;
var services = CreateServiceCollection(workspace, options =>
{
options.NewCommandPrompterFactory = (sp) =>
{
var interactionService = sp.GetRequiredService<IInteractionService>();
var prompter = new TestNewCommandPrompter(interactionService);
prompter.PromptForProjectNameCallback = (defaultName) =>
{
capturedDefaultProjectName = defaultName;
return "CustomName";
};
prompter.PromptForOutputPathCallback = (path) =>
{
capturedDefaultOutputPath = path;
return path;
};
return prompter;
};
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
var result = command.Parse("new aspire-starter --use-redis-cache --test-framework None");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
Assert.Equal("aspire-starter", capturedDefaultProjectName);
Assert.Equal("./CustomName", capturedDefaultOutputPath);
}
[Fact]
public async Task NewCommandDoesNotPromptForProjectNameIfSpecifiedOnCommandLine()
{
var promptedForName = false;
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var services = CreateServiceCollection(workspace, options =>
{
options.NewCommandPrompterFactory = (sp) =>
{
var interactionService = sp.GetRequiredService<IInteractionService>();
var prompter = new TestNewCommandPrompter(interactionService);
prompter.PromptForProjectNameCallback = (defaultName) =>
{
promptedForName = true;
throw new InvalidOperationException("This should not be called");
};
return prompter;
};
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<NewCommand>();
var result = command.Parse("new aspire-starter --name MyApp --output ./output --use-redis-cache --test-framework None");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
Assert.False(promptedForName);
}
[Fact]
public async Task NewCommandDoesNotPromptForOutputPathIfSpecifiedOnCommandLine()
{
bool promptedForPath = false;
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var services = CreateServiceCollection(workspace, options =>
{
options.NewCommandPrompterFactory = (sp) =>
{
var interactionService = sp.GetRequiredService<IInteractionService>();
var prompter = new TestNewCommandPrompter(interactionService);
prompter.PromptForOutputPathCallback = (path) =>
{
promptedForPath = true;
throw new InvalidOperationException("This should not be called");
};
return prompter;
};
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<NewCommand>();
var result = command.Parse("new aspire-starter --output notsrc --use-redis-cache --test-framework None");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
Assert.False(promptedForPath);
}
[Fact]
public async Task NewCommandWithChannelOptionUsesSpecifiedChannel()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
string? channelNameUsed = null;
bool promptedForVersion = false;
var services = CreateServiceCollection(workspace, options =>
{
options.NewCommandPrompterFactory = (sp) =>
{
var interactionService = sp.GetRequiredService<IInteractionService>();
var prompter = new TestNewCommandPrompter(interactionService);
prompter.PromptForTemplatesVersionCallback = (packages) =>
{
promptedForVersion = true;
throw new InvalidOperationException("Should not prompt for version when --channel is specified");
};
return prompter;
};
options.PackagingServiceFactory = (sp) =>
{
var packagingService = new TestPackagingService();
packagingService.GetChannelsAsyncCallback = (ct) =>
{
var stableCache = new FakeNuGetPackageCache();
stableCache.GetTemplatePackagesAsyncCallback = (dir, prerelease, nugetConfig, ct) =>
{
channelNameUsed = "stable";
var package = new NuGetPackage { Id = "Aspire.ProjectTemplates", Source = "nuget", Version = "9.2.0" };
return Task.FromResult<IEnumerable<NuGetPackage>>([package]);
};
var dailyCache = new FakeNuGetPackageCache();
dailyCache.GetTemplatePackagesAsyncCallback = (dir, prerelease, nugetConfig, ct) =>
{
channelNameUsed = "daily";
var package = new NuGetPackage { Id = "Aspire.ProjectTemplates", Source = "nuget", Version = "10.0.0-dev" };
return Task.FromResult<IEnumerable<NuGetPackage>>([package]);
};
var stableChannel = PackageChannel.CreateExplicitChannel("stable", PackageChannelQuality.Both, [], stableCache, new TestFeatures(), NullLogger.Instance);
var dailyChannel = PackageChannel.CreateExplicitChannel("daily", PackageChannelQuality.Both, [], dailyCache, new TestFeatures(), NullLogger.Instance);
return Task.FromResult<IEnumerable<PackageChannel>>([stableChannel, dailyChannel]);
};
return packagingService;
};
options.DotNetCliRunnerFactory = (sp) =>
{
var runner = new TestDotNetCliRunner();
runner.InstallTemplateAsyncCallback = (packageName, version, nugetConfigFile, nugetSource, force, invocationOptions, ct) =>
{
return (0, version);
};
runner.NewProjectAsyncCallback = (templateName, projectName, outputPath, invocationOptions, ct) =>
{
return 0;
};
return runner;
};
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<NewCommand>();
var result = command.Parse("new aspire-starter --channel stable --use-redis-cache --test-framework None");
var exitCode = await result.InvokeAsync().DefaultTimeout();
// Assert
Assert.Equal(CliExitCodes.Success, exitCode);
Assert.Equal("stable", channelNameUsed); // Verify the stable channel was used
Assert.False(promptedForVersion); // Should not prompt when --channel is specified
}
[Fact]
public async Task NewCommandWithChannelOptionAutoSelectsHighestVersion()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
string? selectedVersion = null;
bool promptedForVersion = false;
var services = CreateServiceCollection(workspace, options =>
{
options.NewCommandPrompterFactory = (sp) =>
{
var interactionService = sp.GetRequiredService<IInteractionService>();
var prompter = new TestNewCommandPrompter(interactionService);
prompter.PromptForTemplatesVersionCallback = (packages) =>
{
promptedForVersion = true;
throw new InvalidOperationException("Should not prompt for version when --channel is specified");
};
return prompter;
};
options.PackagingServiceFactory = (sp) =>
{
var packagingService = new TestPackagingService();
packagingService.GetChannelsAsyncCallback = (ct) =>
{
var fakeCache = new FakeNuGetPackageCache();
fakeCache.GetTemplatePackagesAsyncCallback = (dir, prerelease, nugetConfig, ct) =>
{
// Return multiple versions to test auto-selection of highest
var packages = new[]
{
new NuGetPackage { Id = "Aspire.ProjectTemplates", Source = "nuget", Version = "9.0.0" },
new NuGetPackage { Id = "Aspire.ProjectTemplates", Source = "nuget", Version = "9.2.0" },
new NuGetPackage { Id = "Aspire.ProjectTemplates", Source = "nuget", Version = "9.1.0" },
};
return Task.FromResult<IEnumerable<NuGetPackage>>(packages);
};
var stableChannel = PackageChannel.CreateExplicitChannel("stable", PackageChannelQuality.Both, [], fakeCache, new TestFeatures(), NullLogger.Instance);
return Task.FromResult<IEnumerable<PackageChannel>>([stableChannel]);
};
return packagingService;
};
options.DotNetCliRunnerFactory = (sp) =>
{
var runner = new TestDotNetCliRunner();
runner.InstallTemplateAsyncCallback = (packageName, version, nugetConfigFile, nugetSource, force, invocationOptions, ct) =>
{
selectedVersion = version;
return (0, version);
};
runner.NewProjectAsyncCallback = (templateName, projectName, outputPath, invocationOptions, ct) =>
{
return 0; // Success
};
return runner;
};
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<NewCommand>();
var result = command.Parse("new aspire-starter --channel stable --use-redis-cache --test-framework None");
var exitCode = await result.InvokeAsync().DefaultTimeout();
// Assert
Assert.Equal(CliExitCodes.Success, exitCode);
Assert.Equal("9.2.0", selectedVersion); // Should auto-select highest version (9.2.0)
Assert.False(promptedForVersion); // Should not prompt when --channel is specified
}
[Fact]
public async Task NewCommandWithPrChannelPrefersCurrentCliVersion()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var cliVersion = VersionHelper.GetDefaultSdkVersion();
string? selectedVersion = null;
bool promptedForVersion = false;
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.NewCommandPrompterFactory = (sp) =>
{
var interactionService = sp.GetRequiredService<IInteractionService>();
var prompter = new TestNewCommandPrompter(interactionService);
prompter.PromptForTemplatesVersionCallback = (packages) =>
{
promptedForVersion = true;
throw new InvalidOperationException("Should not prompt for version when a PR channel contains the current CLI version.");
};
return prompter;
};
options.PackagingServiceFactory = (sp) =>
{
var packagingService = new TestPackagingService();
packagingService.GetChannelsAsyncCallback = (ct) =>
{
var fakeCache = new FakeNuGetPackageCache();
fakeCache.GetTemplatePackagesAsyncCallback = (dir, prerelease, nugetConfig, ct) =>
{
var packages = new[]
{
new NuGetPackage { Id = "Aspire.ProjectTemplates", Source = "pr-hive", Version = cliVersion },
new NuGetPackage { Id = "Aspire.ProjectTemplates", Source = "pr-hive", Version = "99.0.0" },
};
return Task.FromResult<IEnumerable<NuGetPackage>>(packages);
};
var prChannel = PackageChannel.CreateExplicitChannel("pr-12345", PackageChannelQuality.Both, [], fakeCache, new TestFeatures(), NullLogger.Instance);
return Task.FromResult<IEnumerable<PackageChannel>>([prChannel]);
};
return packagingService;
};
options.DotNetCliRunnerFactory = (sp) =>
{
var runner = new TestDotNetCliRunner();
runner.InstallTemplateAsyncCallback = (packageName, version, nugetConfigFile, nugetSource, force, invocationOptions, ct) =>
{
selectedVersion = version;
return (0, version);
};
runner.NewProjectAsyncCallback = (templateName, projectName, outputPath, invocationOptions, ct) =>
{
return 0;
};
return runner;
};
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<NewCommand>();
var result = command.Parse("new aspire-starter --channel pr-12345 --name TestApp --output ./output --use-redis-cache --test-framework None");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(0, exitCode);
Assert.Equal(cliVersion, selectedVersion);
Assert.False(promptedForVersion);
var config = AspireConfigFile.Load(Path.Combine(workspace.WorkspaceRoot.FullName, "output"));
Assert.NotNull(config);
Assert.Equal("pr-12345", config.Channel);
Assert.Equal(cliVersion, config.SdkVersion);
}
[Fact]
// Quarantined due to flakiness. See linked issue for details.
public async Task NewCommandDoesNotPromptForTemplateIfSpecifiedOnCommandLine()
{
bool promptedForTemplate = false;
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var services = CreateServiceCollection(workspace, options =>
{
options.NewCommandPrompterFactory = (sp) =>
{
var interactionService = sp.GetRequiredService<IInteractionService>();
var prompter = new TestNewCommandPrompter(interactionService);
prompter.PromptForTemplateCallback = (path) =>
{
promptedForTemplate = true;
throw new InvalidOperationException("This should not be called");
};
return prompter;
};
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<NewCommand>();
var result = command.Parse("new aspire-starter --name MyApp --output ./output --use-redis-cache --test-framework None");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
Assert.False(promptedForTemplate);
}
[Fact]
public async Task NewCommandDoesNotPromptForTemplateVersionIfSpecifiedOnCommandLine()
{
bool promptedForTemplateVersion = false;
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var services = CreateServiceCollection(workspace, options =>
{
options.NewCommandPrompterFactory = (sp) =>
{
var interactionService = sp.GetRequiredService<IInteractionService>();
var prompter = new TestNewCommandPrompter(interactionService);
prompter.PromptForTemplatesVersionCallback = (packages) =>
{
promptedForTemplateVersion = true;
throw new InvalidOperationException("This should not be called");
};
return prompter;
};
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<NewCommand>();
var result = command.Parse("new aspire-starter --name MyApp --output ./output --use-redis-cache --test-framework None --version 9.2.0");
var exitCode = await result.InvokeAsync().DefaultTimeout(TestConstants.LongTimeoutDuration);
Assert.Equal(CliExitCodes.Success, exitCode);
Assert.False(promptedForTemplateVersion);
}
[Fact]
public async Task NewCommand_EmptyPackageList_DisplaysErrorMessage()
{
TestInteractionService? testInteractionService = null;
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var services = CreateServiceCollection(workspace, options => {
options.CliHostEnvironmentFactory = _ => TestHelpers.CreateInteractiveHostEnvironment();
options.InteractionServiceFactory = (sp) => {
testInteractionService = new TestInteractionService();
return testInteractionService;
};
options.DotNetCliRunnerFactory = (sp) => {
var runner = new TestDotNetCliRunner();
runner.SearchPackagesAsyncCallback = (dir, query, exactMatch, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) => {
return (0, Array.Empty<NuGetPackage>());
};
return runner;
};
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<NewCommand>();
var result = command.Parse("new");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.FailedToCreateNewProject, exitCode);
Assert.NotNull(testInteractionService);
Assert.Contains(testInteractionService.DisplayedErrors, e => e.Contains(TemplatingStrings.NoTemplateVersionsFound));
}
[Fact]
public async Task NewCommandWithExitCode73ShowsUserFriendlyError()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var services = CreateServiceCollection(workspace, options =>
{
options.NewCommandPrompterFactory = (sp) =>
{
var interactionService = sp.GetRequiredService<IInteractionService>();
return new TestNewCommandPrompter(interactionService);
};
options.DotNetCliRunnerFactory = (sp) =>
{
var runner = CreateTestRunnerWithStandardPackages();
runner.InstallTemplateAsyncCallback = (packageName, version, nugetConfigFile, nugetSource, force, options, cancellationToken) =>
{
return (0, version); // Success, return the template version
};
runner.NewProjectAsyncCallback = (templateName, name, outputPath, options, cancellationToken) =>
{
return 73; // Simulate exit code 73 (directory already contains files)
};
return runner;
};
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
var result = command.Parse("new aspire-starter --use-redis-cache --test-framework None");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.FailedToCreateNewProject, exitCode);
}
private IServiceCollection CreateServiceCollection(
TemporaryWorkspace workspace,
Action<CliServiceCollectionTestOptions>? configure = null)
{
return CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.CliExecutionContextFactory = _ => workspace.CreateExecutionContext(identityChannel: PackageChannelNames.Stable);
options.DotNetCliRunnerFactory = _ => CreateTestRunnerWithStandardPackages();
configure?.Invoke(options);
});
}
private static TestDotNetCliRunner CreateTestRunnerWithStandardPackages()
{
var runner = new TestDotNetCliRunner();
runner.SearchPackagesAsyncCallback = (dir, query, exactMatch, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) =>
{
var package = new NuGetPackage()
{
Id = "Aspire.ProjectTemplates",
Source = "nuget",
Version = "9.2.0"
};
return (0, new NuGetPackage[] { package });
};
return runner;
}
private static void AssertSourceOverrideNuGetConfig(string outputPath, string sourceOverride)
{
var doc = XDocument.Load(Path.Combine(outputPath, "nuget.config"));
var packageSources = doc.Root!.Element("packageSources")!;
Assert.Contains(packageSources.Elements("clear"), _ => true);
Assert.Contains(packageSources.Elements("add"), e => (string?)e.Attribute("value") == sourceOverride);
Assert.Contains(packageSources.Elements("add"), e => (string?)e.Attribute("value") == PackageSources.NuGetOrg);
Assert.Equal(["Aspire*"], GetPackagePatternsForSource(doc, sourceOverride));
Assert.Equal([PackageMapping.AllPackages], GetPackagePatternsForSource(doc, PackageSources.NuGetOrg));
}
private static string[] GetPackagePatternsForSource(XDocument doc, string source)
{
var packageSourceMapping = doc.Root!.Element("packageSourceMapping");
if (packageSourceMapping is null)
{
return [];
}
return packageSourceMapping
.Elements("packageSource")
.Where(e => string.Equals((string?)e.Attribute("key"), source, StringComparison.OrdinalIgnoreCase))
.Elements("package")
.Select(e => (string?)e.Attribute("pattern"))
.Where(pattern => pattern is not null)
.Select(pattern => pattern!)
.ToArray();
}
[Fact]
public async Task NewCommandPromptsForTemplateVersionBeforeTemplateOptions()
{
var operationOrder = new List<string>();
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var services = CreateServiceCollection(workspace, options =>
{
options.CliHostEnvironmentFactory = _ => TestHelpers.CreateInteractiveHostEnvironment();
options.NewCommandPrompterFactory = (sp) =>
{
var interactionService = sp.GetRequiredService<IInteractionService>();
var prompter = new TestNewCommandPrompter(interactionService);
prompter.PromptForTemplatesVersionCallback = (packages) =>
{
operationOrder.Add("TemplateVersion");
return packages.First();
};
return prompter;
};
options.InteractionServiceFactory = (sp) =>
{
var testInteractionService = new TestInteractionService();
testInteractionService.PromptForSelectionCallback = (promptText, choices, formatter, ct) =>
{
// Track template option prompts
if (promptText?.Contains("Redis") == true ||
promptText?.Contains("test framework") == true ||
promptText?.Contains("Create a test project") == true ||
promptText?.Contains("xUnit") == true)
{
operationOrder.Add("TemplateOption");
}
return choices.Cast<object>().First();
};
return testInteractionService;
};
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<NewCommand>();
var result = command.Parse("new aspire-starter --name TestApp --output ./output");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
// Verify that template version was prompted before template options
Assert.Contains("TemplateVersion", operationOrder);
// If template options were prompted, they should come after version selection
var versionIndex = operationOrder.IndexOf("TemplateVersion");
var optionIndex = operationOrder.IndexOf("TemplateOption");
if (optionIndex >= 0)
{
Assert.True(versionIndex < optionIndex,
$"Template version should be prompted before template options. Order: {string.Join(", ", operationOrder)}");
}
}
[Fact]
public async Task NewCommandEscapesMarkupInProjectNameAndOutputPath()
{
// This test validates that project names containing Spectre markup characters
// (like '[' and ']') are properly escaped when displayed as default values in prompts.
// This prevents crashes when the markup parser encounters malformed markup.
var projectNameWithMarkup = "[27;5;13~"; // Example of input that could crash the markup parser
var capturedProjectNameDefault = string.Empty;
var capturedOutputPathDefault = string.Empty;
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var services = CreateServiceCollection(workspace, options =>
{
options.CliHostEnvironmentFactory = _ => TestHelpers.CreateInteractiveHostEnvironment();
options.InteractionServiceFactory = _ => new TestInteractionService();
options.NewCommandPrompterFactory = (sp) =>
{
var interactionService = sp.GetRequiredService<IInteractionService>();
var prompter = new TestNewCommandPrompter(interactionService);
// Simulate user entering a project name with markup characters
prompter.PromptForProjectNameCallback = (defaultName) =>
{
capturedProjectNameDefault = defaultName;
return projectNameWithMarkup;
};
// Capture what default value is passed for the output path
// The path passed to this callback is the unescaped version
prompter.PromptForOutputPathCallback = (path) =>
{
capturedOutputPathDefault = path;
// Return a path with markup characters to verify it doesn't crash
return projectNameWithMarkup;
};
return prompter;
};
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
var result = command.Parse($"new aspire-starter --use-redis-cache --test-framework None");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
// Verify that the default output path is derived from the project name (which contains markup characters)
var expectedPath = $"./{projectNameWithMarkup}";
Assert.Equal(expectedPath, capturedOutputPathDefault);
}
[Fact]
public async Task NewCommandWithoutTemplateCanCreateTypeScriptEmptyTemplate()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var scaffoldedLanguageId = string.Empty;
(string Name, string Description)[]? promptedTemplates = null;
var services = CreateServiceCollection(workspace, options =>
{
options.CliHostEnvironmentFactory = _ => TestHelpers.CreateInteractiveHostEnvironment();
options.FeatureFlagsFactory = _ =>
{
var features = new TestFeatures();
features.SetFeature(KnownFeatures.ExperimentalPolyglotJava, true);
return features;
};
options.InteractionServiceFactory = _ => new TestInteractionService
{
PromptForSelectionCallback = (promptText, choices, choiceFormatter, cancellationToken) =>
promptText == "Which language would you like to use?"
? choices.Cast<object>().Single(choice => choiceFormatter(choice).Contains("TypeScript", StringComparison.Ordinal))
: choices.Cast<object>().First()
};
options.NewCommandPrompterFactory = (sp) =>
{
var interactionService = sp.GetRequiredService<IInteractionService>();
var prompter = new TestNewCommandPrompter(interactionService);
prompter.PromptForTemplateCallback = templates =>
{
promptedTemplates = templates.Select(t => (t.Name, t.Description)).ToArray();
return templates.Single(t => t.Name.Equals(KnownTemplateId.CSharpEmptyAppHost, StringComparison.OrdinalIgnoreCase));
};
return prompter;
};
});
services.AddSingleton<IScaffoldingService>(new TestScaffoldingService
{
ScaffoldAsyncCallback = (context, cancellationToken) =>
{
scaffoldedLanguageId = context.Language.LanguageId.Value;
File.WriteAllText(Path.Combine(context.TargetDirectory.FullName, "apphost.mts"), "// test apphost");
return Task.FromResult(true);
}
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<NewCommand>();
var result = command.Parse("new --name TestApp --output ./output");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
Assert.Equal(KnownLanguageId.TypeScript, scaffoldedLanguageId);
Assert.NotNull(promptedTemplates);
Assert.Contains((KnownTemplateId.CSharpEmptyAppHost, "Empty AppHost (Choose language...)"), promptedTemplates);
Assert.DoesNotContain((KnownTemplateId.TypeScriptEmptyAppHost, "Empty (TypeScript AppHost)"), promptedTemplates);
Assert.DoesNotContain((KnownTemplateId.JavaEmptyAppHost, "Empty (Java AppHost)"), promptedTemplates);
Assert.Contains((KnownTemplateId.TypeScriptStarter, "Starter App (Express/React, TypeScript AppHost)"), promptedTemplates);
Assert.True(File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, "output", "apphost.mts")));
Assert.False(File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, "output", "aspire.config.json")));
}
[Fact]
public void NewCommandTemplateSubcommandsListTechnicalNamesForNonInteractiveFlows()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var services = CreateServiceCollection(workspace, options =>
{
options.FeatureFlagsFactory = _ => new TestFeatures().SetFeature(KnownFeatures.ShowAllTemplates, true);
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<NewCommand>();
Assert.Contains(command.Subcommands, subcommand => subcommand.Name == "aspire-test");
Assert.Contains(command.Subcommands, subcommand => subcommand.Name == KnownTemplateId.DotNetEmptyAppHost && subcommand.Description == "Empty (C# AppHost, dotnet template)");
Assert.Contains(command.Subcommands, subcommand => subcommand.Name == KnownTemplateId.CSharpEmptyAppHost && subcommand.Description == "Empty AppHost (Choose language...)");
Assert.Contains(command.Subcommands, subcommand => subcommand.Name == KnownTemplateId.TypeScriptEmptyAppHost && subcommand.Description == "Empty (TypeScript AppHost)");
}
[Fact]
public async Task NewCommandWithoutTemplatePromptsWithSingleGenericEmptyTemplate()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
string[]? promptedTemplateDescriptions = null;
var services = CreateServiceCollection(workspace, options =>
{
options.CliHostEnvironmentFactory = _ => TestHelpers.CreateInteractiveHostEnvironment();
options.FeatureFlagsFactory = _ =>
{
var features = new TestFeatures();
features.SetFeature(KnownFeatures.ExperimentalPolyglotGo, true);
features.SetFeature(KnownFeatures.ExperimentalPolyglotJava, true);
features.SetFeature(KnownFeatures.ExperimentalPolyglotPython, true);
features.SetFeature(KnownFeatures.ExperimentalPolyglotRust, true);
return features;
};
options.InteractionServiceFactory = _ => new TestInteractionService();
options.NewCommandPrompterFactory = (sp) =>
{
var interactionService = sp.GetRequiredService<IInteractionService>();
var prompter = new TestNewCommandPrompter(interactionService);
prompter.PromptForTemplateCallback = templates =>
{
promptedTemplateDescriptions = templates
.Where(t => t.IsEmpty)
.Select(t => t.Description)
.ToArray();
return templates.Single(t => t.Name.Equals(KnownTemplateId.CSharpEmptyAppHost, StringComparison.OrdinalIgnoreCase));
};
return prompter;
};
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<NewCommand>();
var result = command.Parse("new --name TestApp --output ./output");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
Assert.NotNull(promptedTemplateDescriptions);
Assert.Equal(["Empty AppHost (Choose language...)"], promptedTemplateDescriptions);
}
[Fact]
public async Task NewCommandWithEmptyTemplateOmitsDisabledLanguagesFromLanguagePrompt()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
string[]? promptedLanguages = null;
var services = CreateServiceCollection(workspace, options =>
{
options.CliHostEnvironmentFactory = _ => TestHelpers.CreateInteractiveHostEnvironment();
options.InteractionServiceFactory = _ => new TestInteractionService
{
PromptForSelectionCallback = (promptText, choices, choiceFormatter, cancellationToken) =>
{
if (promptText == "Which language would you like to use?")
{
promptedLanguages = choices.Cast<object>()
.Select(choice => choiceFormatter(choice))
.ToArray();
}
return choices.Cast<object>().First();
}
};
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<NewCommand>();
var result = command.Parse("new aspire-empty --name TestApp --output ./output");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
Assert.NotNull(promptedLanguages);
Assert.Contains(KnownLanguageId.CSharpDisplayName, promptedLanguages);
Assert.Contains("TypeScript (Node.js)", promptedLanguages);
Assert.DoesNotContain(KnownLanguageId.PythonDisplayName, promptedLanguages);
Assert.DoesNotContain(KnownLanguageId.JavaDisplayName, promptedLanguages);
Assert.DoesNotContain(KnownLanguageId.GoDisplayName, promptedLanguages);
Assert.DoesNotContain(KnownLanguageId.RustDisplayName, promptedLanguages);
}
[Fact]
public async Task NewCommandWithEmptyTemplatePromptsForEnabledLanguages()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
string[]? promptedLanguages = null;
string? scaffoldedLanguageId = null;
var services = CreateServiceCollection(workspace, options =>
{
options.CliHostEnvironmentFactory = _ => TestHelpers.CreateInteractiveHostEnvironment();
options.FeatureFlagsFactory = _ =>
{
var features = new TestFeatures();
features.SetFeature(KnownFeatures.ExperimentalPolyglotJava, true);
features.SetFeature(KnownFeatures.ExperimentalPolyglotPython, true);
return features;
};
options.InteractionServiceFactory = _ => new TestInteractionService
{
PromptForSelectionCallback = (promptText, choices, choiceFormatter, cancellationToken) =>
{
var formattedChoices = choices.Cast<object>()
.Select(choice => choiceFormatter(choice))
.ToArray();
if (promptText == "Which language would you like to use?")
{
promptedLanguages = formattedChoices;
return choices.Cast<object>().Single(choice => string.Equals(choiceFormatter(choice), KnownLanguageId.JavaDisplayName, StringComparison.Ordinal));
}
return choices.Cast<object>().First();
}
};
});
services.AddSingleton<IScaffoldingService>(new TestScaffoldingService
{
ScaffoldAsyncCallback = (context, cancellationToken) =>
{
scaffoldedLanguageId = context.Language.LanguageId.Value;
File.WriteAllText(Path.Combine(context.TargetDirectory.FullName, "AppHost.java"), "package aspire;");
return Task.FromResult(true);
}
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<NewCommand>();
var result = command.Parse("new aspire-empty --name TestApp --output ./output");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
Assert.Equal(KnownLanguageId.Java, scaffoldedLanguageId);
Assert.NotNull(promptedLanguages);
Assert.Contains(KnownLanguageId.CSharpDisplayName, promptedLanguages);
Assert.Contains("TypeScript (Node.js)", promptedLanguages);
Assert.Contains(KnownLanguageId.PythonDisplayName, promptedLanguages);
Assert.Contains(KnownLanguageId.JavaDisplayName, promptedLanguages);
Assert.DoesNotContain(KnownLanguageId.GoDisplayName, promptedLanguages);
Assert.DoesNotContain(KnownLanguageId.RustDisplayName, promptedLanguages);
}
[Fact]
public async Task NewCommandWithEmptyTemplateIgnoresConfiguredLanguage()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
File.WriteAllText(Path.Combine(workspace.WorkspaceRoot.FullName, "aspire.config.json"), """
{
"language": "typescript/nodejs"
}
""");
var languagePrompted = false;
string? scaffoldedLanguageId = null;
var services = CreateServiceCollection(workspace, options =>
{
options.CliHostEnvironmentFactory = _ => TestHelpers.CreateInteractiveHostEnvironment();
options.FeatureFlagsFactory = _ =>
{
var features = new TestFeatures();
features.SetFeature(KnownFeatures.ExperimentalPolyglotJava, true);
return features;
};
options.InteractionServiceFactory = _ => new TestInteractionService
{
PromptForSelectionCallback = (promptText, choices, choiceFormatter, cancellationToken) =>
{
if (promptText == "Which language would you like to use?")
{
languagePrompted = true;
return choices.Cast<object>().Single(choice => string.Equals(choiceFormatter(choice), KnownLanguageId.JavaDisplayName, StringComparison.Ordinal));
}
return choices.Cast<object>().First();
}
};
});
services.AddSingleton<IScaffoldingService>(new TestScaffoldingService
{
ScaffoldAsyncCallback = (context, cancellationToken) =>
{
scaffoldedLanguageId = context.Language.LanguageId.Value;
File.WriteAllText(Path.Combine(context.TargetDirectory.FullName, "AppHost.java"), "package aspire;");
return Task.FromResult(true);
}
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<NewCommand>();
var result = command.Parse("new aspire-empty --name TestApp --output ./output");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
Assert.True(languagePrompted);
Assert.Equal(KnownLanguageId.Java, scaffoldedLanguageId);
}
[Fact]
public async Task NewCommandWithExplicitLanguageAfterEmptyTemplateSubcommandCreatesTypeScriptAppHost()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
string? scaffoldedLanguageId = null;
var services = CreateServiceCollection(workspace);
services.AddSingleton<IScaffoldingService>(new TestScaffoldingService
{
ScaffoldAsyncCallback = (context, cancellationToken) =>
{
scaffoldedLanguageId = context.Language.LanguageId.Value;
File.WriteAllText(Path.Combine(context.TargetDirectory.FullName, "apphost.mts"), "// test apphost");
return Task.FromResult(true);
}
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<NewCommand>();
var result = command.Parse("new aspire-empty --name TestApp --output ./output --language typescript --localhost-tld false --suppress-agent-init");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
Assert.Equal(KnownLanguageId.TypeScript, scaffoldedLanguageId);
Assert.True(File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, "output", "apphost.mts")));
}
[Fact]
public async Task NewCommandWithCSharpEmptyTemplateAndSourceOverrideUsesSourceForTemplateDiscovery()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
const string sourceOverride = "https://proxy.example/v3/index.json";
var expectedSource = sourceOverride;
string? discoveryAspireSource = null;
string? discoveryFallbackSource = null;
var cache = new FakeNuGetPackageCache
{
GetTemplatePackagesAsyncCallback = (_, _, nugetConfig, _) =>
{
Assert.NotNull(nugetConfig);
var document = XDocument.Load(nugetConfig.FullName);
var sourceMappings = document.Root!
.Element("packageSourceMapping")!
.Elements("packageSource");
discoveryAspireSource = (string?)sourceMappings
.Single(source => source
.Elements("package")
.Any(package => (string?)package.Attribute("pattern") == "Aspire*"))
.Attribute("key");
discoveryFallbackSource = (string?)sourceMappings
.Single(source => source
.Elements("package")
.Any(package => (string?)package.Attribute("pattern") == PackageMapping.AllPackages))
.Attribute("key");
return Task.FromResult<IEnumerable<NuGetPackage>>(
[new NuGetPackage { Id = "Aspire.ProjectTemplates", Source = expectedSource, Version = "9.2.0" }]);
}
};
var channel = PackageChannel.CreateExplicitChannel(
PackageChannelNames.Staging,
PackageChannelQuality.Stable,
[
new PackageMapping("Aspire*", "https://channel.example/v3/index.json"),
new PackageMapping(PackageMapping.AllPackages, PackageSources.NuGetOrg)
],
cache,
new TestFeatures(),
NullLogger.Instance);
var services = CreateServiceCollection(workspace, options =>
{
options.PackagingServiceFactory = _ => new TestPackagingService
{
GetChannelsAsyncCallback = _ => Task.FromResult<IEnumerable<PackageChannel>>([channel])
};
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<NewCommand>();
var result = command.Parse($"new aspire-empty --name TestApp --output ./output --language csharp --localhost-tld false --suppress-agent-init --channel staging --source {sourceOverride}");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
Assert.Equal(expectedSource, discoveryAspireSource);
Assert.Equal(expectedSource, discoveryFallbackSource);
AssertSourceOverrideNuGetConfig(Path.Combine(workspace.WorkspaceRoot.FullName, "output"), expectedSource);
}
[Fact]
public async Task NewCommandWithCSharpEmptyTemplateAndRelativeLocalSourceOverrideDiscoversTemplatesFromResolvedDirectory()
{
// A local directory passed to --source is enumerated directly instead of going through
// `dotnet package search`, which cannot see hierarchical local feeds. The relative path must
// still be resolved against the invocation directory before it is used or persisted.
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var expectedSource = Path.Combine(workspace.WorkspaceRoot.FullName, "relative-feed");
var nestedDirectory = Directory.CreateDirectory(Path.Combine(expectedSource, "aspire.projecttemplates", "9.2.0"));
File.WriteAllText(Path.Combine(nestedDirectory.FullName, "Aspire.ProjectTemplates.9.2.0.nupkg"), string.Empty);
var cache = new FakeNuGetPackageCache
{
GetTemplatePackagesAsyncCallback = (_, _, _, _) => throw new InvalidOperationException("Local package sources should be enumerated directly.")
};
var channel = PackageChannel.CreateExplicitChannel(
PackageChannelNames.Staging,
PackageChannelQuality.Stable,
[
new PackageMapping("Aspire*", "https://channel.example/v3/index.json"),
new PackageMapping(PackageMapping.AllPackages, PackageSources.NuGetOrg)
],
cache,
new TestFeatures(),
NullLogger.Instance);
var services = CreateServiceCollection(workspace, options =>
{
options.PackagingServiceFactory = _ => new TestPackagingService
{
GetChannelsAsyncCallback = _ => Task.FromResult<IEnumerable<PackageChannel>>([channel])
};
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<NewCommand>();
var result = command.Parse("new aspire-empty --name TestApp --output ./output --language csharp --localhost-tld false --suppress-agent-init --channel staging --source relative-feed");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
AssertSourceOverrideNuGetConfig(Path.Combine(workspace.WorkspaceRoot.FullName, "output"), expectedSource);
}
[Theory]
[InlineData("typescript", null, "apphost.mts")]
[InlineData("java", "experimentalPolyglot:java", "AppHost.java")]
[InlineData("python", "experimentalPolyglot:python", "apphost.py")]
[InlineData("go", "experimentalPolyglot:go", "apphost.go")]
[InlineData("rust", "experimentalPolyglot:rust", "apphost.rs")]
public async Task NewCommandWithEmptyTemplateAndSourceOverridePersistsSourceForLaterRestore(string language, string? featureFlag, string scaffoldFileName)
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var sourceOverride = Path.Combine(workspace.WorkspaceRoot.FullName, "source-feed");
Directory.CreateDirectory(sourceOverride);
File.WriteAllText(Path.Combine(sourceOverride, "Aspire.ProjectTemplates.9.2.0.nupkg"), string.Empty);
string? capturedPackageSourceOverride = null;
TestInteractionService? interactionService = null;
var services = CreateServiceCollection(workspace, options =>
{
options.InteractionServiceFactory = _ => interactionService = new TestInteractionService();
if (featureFlag is not null)
{
options.FeatureFlagsFactory = _ =>
{
var features = new TestFeatures();
features.SetFeature(featureFlag, true);
return features;
};
}
});
services.AddSingleton<IScaffoldingService>(new TestScaffoldingService
{
ScaffoldAsyncCallback = (context, _) =>
{
capturedPackageSourceOverride = context.PackageSourceOverride;
File.WriteAllText(Path.Combine(context.TargetDirectory.FullName, scaffoldFileName), "// test apphost");
return Task.FromResult(true);
}
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<NewCommand>();
var result = command.Parse($"new aspire-empty --name TestApp --output ./output --language {language} --localhost-tld false --suppress-agent-init --source \"{sourceOverride}\"");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
Assert.Equal(sourceOverride, capturedPackageSourceOverride);
AssertSourceOverrideNuGetConfig(Path.Combine(workspace.WorkspaceRoot.FullName, "output"), sourceOverride);
Assert.NotNull(interactionService);
Assert.DoesNotContain(
interactionService!.DisplayedMessages,
entry => entry.Message == TemplatingStrings.SourceOverrideNotPersistedWarning);
}
[Fact]
public async Task NewCommandWithCSharpEmptyTemplateAndSourceOverridePersistsSourceForLaterRestore()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var sourceOverride = Path.Combine(workspace.WorkspaceRoot.FullName, "source-feed");
Directory.CreateDirectory(sourceOverride);
File.WriteAllText(Path.Combine(sourceOverride, "Aspire.ProjectTemplates.9.2.0.nupkg"), string.Empty);
var services = CreateServiceCollection(workspace);
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<NewCommand>();
var result = command.Parse($"new aspire-empty --name TestApp --output ./output --language csharp --localhost-tld false --suppress-agent-init --source \"{sourceOverride}\"");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
AssertSourceOverrideNuGetConfig(Path.Combine(workspace.WorkspaceRoot.FullName, "output"), sourceOverride);
}
[Theory]
[InlineData("https://user:token@example.invalid/v3/index.json")]
[InlineData("https://example.invalid/v3/index.json?sig=token")]
[InlineData("https://example.invalid/v3/index.json#token")]
public async Task NewCommandWithCredentialBearingHttpSourceFailsBeforeCreatingProject(string sourceOverride)
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var scaffoldingInvoked = false;
TestInteractionService? interactionService = null;
var services = CreateServiceCollection(workspace, options =>
{
options.InteractionServiceFactory = _ => interactionService = new TestInteractionService();
});
services.AddSingleton<IScaffoldingService>(new TestScaffoldingService
{
ScaffoldAsyncCallback = (_, _) =>
{
scaffoldingInvoked = true;
return Task.FromResult(true);
}
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<NewCommand>();
var result = command.Parse($"new aspire-empty --name TestApp --output ./output --language typescript --localhost-tld false --suppress-agent-init --source {sourceOverride}");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.InvalidCommand, exitCode);
Assert.False(scaffoldingInvoked);
Assert.False(Directory.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, "output")));
Assert.NotNull(interactionService);
Assert.Contains(NewCommandStrings.SourceWithCredentialsCannotBePersisted, interactionService!.DisplayedErrors);
}
[Fact]
public async Task NewCommandWithMissingLocalSourceFailsBeforeCreatingProject()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var scaffoldingInvoked = false;
TestInteractionService? interactionService = null;
var services = CreateServiceCollection(workspace, options =>
{
options.InteractionServiceFactory = _ => interactionService = new TestInteractionService();
});
services.AddSingleton<IScaffoldingService>(new TestScaffoldingService
{
ScaffoldAsyncCallback = (_, _) =>
{
scaffoldingInvoked = true;
return Task.FromResult(true);
}
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<NewCommand>();
var result = command.Parse("new aspire-empty --name TestApp --output ./output --language typescript --localhost-tld false --suppress-agent-init --source nuget.org");
var exitCode = await result.InvokeAsync().DefaultTimeout();
var expectedSource = Path.Combine(workspace.WorkspaceRoot.FullName, "nuget.org");
Assert.Equal(CliExitCodes.InvalidCommand, exitCode);
Assert.False(scaffoldingInvoked);
Assert.False(Directory.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, "output")));
Assert.NotNull(interactionService);
Assert.Contains(interactionService!.DisplayedErrors, error => error.Contains(expectedSource, StringComparison.Ordinal));
}
[Fact]
public async Task NewCommandWithEmptyTemplateWithoutSourceOverrideDoesNotWarn()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
TestInteractionService? interactionService = null;
var services = CreateServiceCollection(workspace, options =>
{
options.InteractionServiceFactory = _ => interactionService = new TestInteractionService();
});
services.AddSingleton<IScaffoldingService>(new TestScaffoldingService
{
ScaffoldAsyncCallback = (context, _) =>
{
File.WriteAllText(Path.Combine(context.TargetDirectory.FullName, "apphost.mts"), "// test apphost");
return Task.FromResult(true);
}
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<NewCommand>();
var result = command.Parse("new aspire-empty --name TestApp --output ./output --language typescript --localhost-tld false --suppress-agent-init");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
Assert.NotNull(interactionService);
Assert.DoesNotContain(
interactionService!.DisplayedMessages,
entry => entry.Message == TemplatingStrings.SourceOverrideNotPersistedWarning);
}
[Fact]
public async Task NewCommandWithExplicitJavaEmptyTemplateCreatesJavaAppHost()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
string? scaffoldedLanguageId = null;
var services = CreateServiceCollection(workspace, options =>
{
options.FeatureFlagsFactory = _ =>
{
var features = new TestFeatures();
features.SetFeature(KnownFeatures.ExperimentalPolyglotJava, true);
return features;
};
});
services.AddSingleton<IScaffoldingService>(new TestScaffoldingService
{
ScaffoldAsyncCallback = (context, cancellationToken) =>
{
scaffoldedLanguageId = context.Language.LanguageId.Value;
File.WriteAllText(Path.Combine(context.TargetDirectory.FullName, "AppHost.java"), "package aspire;");
return Task.FromResult(true);
}
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<NewCommand>();
var result = command.Parse("new aspire-java-empty --name TestApp --output ./output --localhost-tld false");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
Assert.Equal(KnownLanguageId.Java, scaffoldedLanguageId);
Assert.True(File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, "output", "AppHost.java")));
}
[Fact]
public async Task NewCommandWithExplicitPythonEmptyTemplateCreatesPythonAppHost()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
string? scaffoldedLanguageId = null;
var services = CreateServiceCollection(workspace, options =>
{
options.FeatureFlagsFactory = _ =>
{
var features = new TestFeatures();
features.SetFeature(KnownFeatures.ExperimentalPolyglotPython, true);
return features;
};
});
services.AddSingleton<IScaffoldingService>(new TestScaffoldingService
{
ScaffoldAsyncCallback = (context, cancellationToken) =>
{
scaffoldedLanguageId = context.Language.LanguageId.Value;
File.WriteAllText(Path.Combine(context.TargetDirectory.FullName, "apphost.py"), "# test apphost");
return Task.FromResult(true);
}
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<NewCommand>();
var result = command.Parse("new aspire-py-empty --name TestApp --output ./output --localhost-tld false");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
Assert.Equal(KnownLanguageId.Python, scaffoldedLanguageId);
Assert.True(File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, "output", "apphost.py")));
}
[Fact]
public async Task NewCommandWithExplicitCSharpEmptyTemplateCreatesCSharpAppHost()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var services = CreateServiceCollection(workspace);
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<NewCommand>();
var result = command.Parse("new aspire-empty --name TestApp --output ./output --localhost-tld false");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
Assert.True(File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, "output", "apphost.cs")));
}
[Fact]
public async Task NewCommandWaitsForBundleExtractionAfterCreatingAppHost()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var extractionStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var allowExtraction = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var bundleService = new TestBundleService(isBundle: true)
{
EnsureExtractedAsyncCallback = async cancellationToken =>
{
extractionStarted.SetResult();
await allowExtraction.Task.WaitAsync(cancellationToken);
}
};
var services = CreateServiceCollection(workspace, options =>
{
options.BundleServiceFactory = _ => bundleService;
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<NewCommand>();
var result = command.Parse("new aspire-empty --name TestApp --output ./output --localhost-tld false --suppress-agent-init");
var invocationTask = result.InvokeAsync();
await extractionStarted.Task.DefaultTimeout();
var appHostCreated = File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, "output", "apphost.cs"));
var commandCompletedBeforeExtraction = invocationTask.IsCompleted;
allowExtraction.SetResult();
var exitCode = await invocationTask.DefaultTimeout();
Assert.True(appHostCreated);
Assert.False(commandCompletedBeforeExtraction);
Assert.Equal(CliExitCodes.Success, exitCode);
}
[Fact]
public async Task NewCommandWithCSharpEmptyTemplateEmitsAppHostRunJsonAndAspireConfigJsonWithoutDuplicateProfiles()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var services = CreateServiceCollection(workspace);
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<NewCommand>();
var result = command.Parse("new aspire-empty --name TestApp --output ./output --localhost-tld false --suppress-agent-init");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
var outputDir = Path.Combine(workspace.WorkspaceRoot.FullName, "output");
var aspireConfigPath = Path.Combine(outputDir, "aspire.config.json");
var appHostRunJsonPath = Path.Combine(outputDir, "apphost.run.json");
Assert.True(File.Exists(aspireConfigPath));
Assert.True(File.Exists(appHostRunJsonPath), "apphost.run.json must be emitted alongside aspire.config.json so dotnet run picks up matching URLs.");
var aspireConfig = await File.ReadAllTextAsync(aspireConfigPath);
var appHostRunJson = await File.ReadAllTextAsync(appHostRunJsonPath);
// Launch profile shape (applicationUrl / commandName / environmentVariables) must live in
// apphost.run.json so that `dotnet run apphost.cs` and the C# Dev Kit can pick it up.
Assert.Contains("://localhost:", appHostRunJson);
Assert.Contains("\"commandName\": \"Project\"", appHostRunJson);
// aspire.config.json must NOT carry a duplicated `profiles` block — that content belongs to
// apphost.run.json only. See https://github.com/microsoft/aspire/issues/17660.
AssertAspireConfigHasNoProfiles(aspireConfig);
}
[Fact]
public async Task NewCommandWithCSharpEmptyTemplateAndLocalhostTldEmitsAppHostRunJsonWithDevLocalhostUrls()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var services = CreateServiceCollection(workspace);
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<NewCommand>();
var result = command.Parse("new aspire-empty --name TestApp --output ./output --localhost-tld --suppress-agent-init");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
var outputDir = Path.Combine(workspace.WorkspaceRoot.FullName, "output");
var aspireConfigPath = Path.Combine(outputDir, "aspire.config.json");
var appHostRunJsonPath = Path.Combine(outputDir, "apphost.run.json");
Assert.True(File.Exists(aspireConfigPath));
Assert.True(File.Exists(appHostRunJsonPath), "apphost.run.json must be emitted alongside aspire.config.json so dotnet run picks up matching URLs.");
var aspireConfig = await File.ReadAllTextAsync(aspireConfigPath);
var appHostRunJson = await File.ReadAllTextAsync(appHostRunJsonPath);
Assert.Contains("testapp.dev.localhost", appHostRunJson);
Assert.DoesNotContain("://localhost", appHostRunJson);
// aspire.config.json must NOT carry a duplicated `profiles` block — that content belongs to
// apphost.run.json only. See https://github.com/microsoft/aspire/issues/17660.
AssertAspireConfigHasNoProfiles(aspireConfig);
}
private static void AssertAspireConfigHasNoProfiles(string aspireConfigJson)
{
using var aspireDoc = System.Text.Json.JsonDocument.Parse(aspireConfigJson);
Assert.False(
aspireDoc.RootElement.TryGetProperty("profiles", out _),
"aspire.config.json must not contain a 'profiles' block for the empty C# template; profiles live in apphost.run.json.");
// Pin the expected minimal shape: aspire.config.json for the C# Empty template should only
// identify the AppHost file. See https://github.com/microsoft/aspire/issues/17660.
Assert.True(aspireDoc.RootElement.TryGetProperty("appHost", out var appHost), "aspire.config.json is missing the required 'appHost' object.");
Assert.True(appHost.TryGetProperty("path", out var path), "aspire.config.json#appHost is missing the 'path' property.");
Assert.Equal("apphost.cs", path.GetString());
}
[Fact]
public async Task NewCommandWithEmptyTemplateAndCSharpPromptsForLocalhostTldAndUsesConfirmation()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var localhostPrompted = false;
var services = CreateServiceCollection(workspace, options =>
{
options.CliHostEnvironmentFactory = _ => TestHelpers.CreateInteractiveHostEnvironment();
options.InteractionServiceFactory = _ => new TestInteractionService
{
ConfirmCallback = (promptText, defaultValue) =>
{
if (string.Equals(promptText, TemplatingStrings.UseLocalhostTld_Prompt, StringComparison.Ordinal))
{
localhostPrompted = true;
Assert.False(defaultValue);
return true;
}
return false;
}
};
options.NewCommandPrompterFactory = (sp) =>
{
var interactionService = sp.GetRequiredService<IInteractionService>();
var prompter = new TestNewCommandPrompter(interactionService);
prompter.PromptForTemplateCallback = templates =>
templates.Single(t => t.Name.Equals("aspire-empty", StringComparison.OrdinalIgnoreCase));
return prompter;
};
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<NewCommand>();
var result = command.Parse("new aspire-empty --name TestApp --output ./output");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
Assert.True(localhostPrompted);
var outputRoot = Path.Combine(workspace.WorkspaceRoot.FullName, "output");
var aspireConfigPath = Path.Combine(outputRoot, "aspire.config.json");
Assert.True(File.Exists(aspireConfigPath));
var aspireConfig = await File.ReadAllTextAsync(aspireConfigPath);
// aspire.config.json must NOT contain the localhost-TLD URLs — those belong in
// apphost.run.json. See https://github.com/microsoft/aspire/issues/17660.
Assert.DoesNotContain("testapp.dev.localhost", aspireConfig);
var appHostRunJsonPath = Path.Combine(outputRoot, "apphost.run.json");
Assert.True(File.Exists(appHostRunJsonPath));
var appHostRunJson = await File.ReadAllTextAsync(appHostRunJsonPath);
Assert.Contains("testapp.dev.localhost", appHostRunJson);
Assert.DoesNotContain("://localhost", appHostRunJson);
}
[Fact]
public async Task NewCommandWithTypeScriptEmptyTemplateUsesScaffolding()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var scaffoldingInvoked = false;
var services = CreateServiceCollection(workspace);
services.AddSingleton<IScaffoldingService>(new TestScaffoldingService
{
ScaffoldAsyncCallback = (context, cancellationToken) =>
{
scaffoldingInvoked = true;
return Task.FromResult(true);
}
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
var result = command.Parse("new aspire-ts-empty --name TestApp --output ./output --localhost-tld false");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
Assert.True(scaffoldingInvoked);
}
[Fact]
public async Task NewCommandWithTypeScriptEmptyTemplatePassesResolvedVersionAndChannelToScaffolding()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
string? scaffoldSdkVersion = null;
string? scaffoldChannel = null;
var services = CreateServiceCollection(workspace, options =>
{
options.PackagingServiceFactory = (sp) =>
{
var packagingService = new TestPackagingService();
packagingService.GetChannelsAsyncCallback = (ct) =>
{
var stableCache = new FakeNuGetPackageCache();
stableCache.GetTemplatePackagesAsyncCallback = (dir, prerelease, nugetConfig, ct) =>
{
var package = new NuGetPackage { Id = "Aspire.ProjectTemplates", Source = "nuget", Version = "9.2.0" };
return Task.FromResult<IEnumerable<NuGetPackage>>([package]);
};
var stableChannel = PackageChannel.CreateExplicitChannel("stable", PackageChannelQuality.Both, [], stableCache, new TestFeatures(), NullLogger.Instance);
return Task.FromResult<IEnumerable<PackageChannel>>([stableChannel]);
};
return packagingService;
};
});
services.AddSingleton<IScaffoldingService>(new TestScaffoldingService
{
ScaffoldAsyncCallback = (context, cancellationToken) =>
{
scaffoldSdkVersion = context.SdkVersion;
scaffoldChannel = context.Channel;
return Task.FromResult(true);
}
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
var result = command.Parse("new aspire-ts-empty --name TestApp --output ./output --channel stable --localhost-tld false");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
Assert.Equal("9.2.0", scaffoldSdkVersion);
Assert.Equal("stable", scaffoldChannel);
}
[Fact]
public async Task NewCommandWithEmptyTemplateNormalizesDefaultOutputPath()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
string? capturedTargetDirectory = null;
var services = CreateServiceCollection(workspace, options =>
{
options.NewCommandPrompterFactory = (sp) =>
{
var interactionService = sp.GetRequiredService<IInteractionService>();
var prompter = new TestNewCommandPrompter(interactionService);
// Accept the default path from the prompt
prompter.PromptForOutputPathCallback = (path) => path;
return prompter;
};
});
services.AddSingleton<IScaffoldingService>(new TestScaffoldingService
{
ScaffoldAsyncCallback = (context, cancellationToken) =>
{
capturedTargetDirectory = context.TargetDirectory.FullName;
return Task.FromResult(true);
}
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
// Do not pass --output so the default project-name path is used via the prompter
var result = command.Parse("new aspire-ts-empty --name TestApp --localhost-tld false");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
Assert.NotNull(capturedTargetDirectory);
// The output path should be properly normalized without "./" segments
Assert.DoesNotContain("./", capturedTargetDirectory);
Assert.DoesNotContain(".\\", capturedTargetDirectory);
var expectedPath = Path.Combine(workspace.WorkspaceRoot.FullName, "TestApp");
Assert.Equal(expectedPath, capturedTargetDirectory);
}
[Fact]
public async Task NewCommandWithEmptyTemplateAndTypeScriptPromptsForLocalhostTldAndUsesConfirmation()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var scaffoldingInvoked = false;
var localhostPrompted = false;
var services = CreateServiceCollection(workspace, options =>
{
options.CliHostEnvironmentFactory = _ => TestHelpers.CreateInteractiveHostEnvironment();
options.InteractionServiceFactory = _ => new TestInteractionService
{
ConfirmCallback = (promptText, defaultValue) =>
{
if (string.Equals(promptText, TemplatingStrings.UseLocalhostTld_Prompt, StringComparison.Ordinal))
{
localhostPrompted = true;
Assert.False(defaultValue);
return true;
}
return false;
}
};
options.NewCommandPrompterFactory = (sp) =>
{
var interactionService = sp.GetRequiredService<IInteractionService>();
var prompter = new TestNewCommandPrompter(interactionService);
prompter.PromptForTemplateCallback = templates =>
templates.Single(t => t.Name.Equals("aspire-ts-empty", StringComparison.OrdinalIgnoreCase));
return prompter;
};
});
services.AddSingleton<IScaffoldingService>(new TestScaffoldingService
{
ScaffoldAsyncCallback = async (context, cancellationToken) =>
{
scaffoldingInvoked = true;
await File.WriteAllTextAsync(Path.Combine(context.TargetDirectory.FullName, "aspire.config.json"), """
{
"appHost": {
"path": "apphost.mts",
"language": "typescript/nodejs"
},
"profiles": {
"https": {
"applicationUrl": "https://localhost:1234;http://localhost:5678",
"environmentVariables": {
"ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:8765",
"ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:4321"
}
}
}
}
""", cancellationToken);
return true;
}
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<NewCommand>();
var result = command.Parse("new aspire-ts-empty --name TestApp --output ./output");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
Assert.True(scaffoldingInvoked);
Assert.True(localhostPrompted);
var configPath = Path.Combine(workspace.WorkspaceRoot.FullName, "output", "aspire.config.json");
var configContent = await File.ReadAllTextAsync(configPath);
Assert.Contains("testapp.dev.localhost", configContent);
Assert.DoesNotContain("://localhost", configContent);
}
[Fact]
public async Task NewCommandWithTypeScriptStarterGeneratesSdkArtifacts()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var buildAndGenerateCalled = false;
string? channelSeenByProject = null;
string? sdkVersionSeenByProject = null;
var services = CreateServiceCollection(workspace, options =>
{
options.DotNetCliRunnerFactory = _ => new TestDotNetCliRunner
{
SearchPackagesAsyncCallback = (dir, query, exactMatch, prerelease, take, skip, nugetSource, useCache, runnerOptions, cancellationToken) =>
{
var package = new NuGetPackage
{
Id = "Aspire.ProjectTemplates",
Source = "nuget",
Version = "9.2.0"
};
return (0, new NuGetPackage[] { package });
}
};
options.PackagingServiceFactory = _ => new TestPackagingService
{
GetChannelsAsyncCallback = cancellationToken =>
{
var dailyCache = new FakeNuGetPackageCache
{
GetTemplatePackagesAsyncCallback = (dir, prerelease, nugetConfig, ct) =>
{
var package = new NuGetPackage
{
Id = "Aspire.ProjectTemplates",
Source = "nuget",
Version = "9.2.0"
};
return Task.FromResult<IEnumerable<NuGetPackage>>([package]);
}
};
var dailyChannel = PackageChannel.CreateExplicitChannel("daily", PackageChannelQuality.Both, [], dailyCache, new TestFeatures(), NullLogger.Instance);
return Task.FromResult<IEnumerable<PackageChannel>>([dailyChannel]);
}
};
});
services.AddSingleton<IAppHostProjectFactory>(new TestTypeScriptStarterProjectFactory((directory, cancellationToken, _) =>
{
buildAndGenerateCalled = true;
var config = AspireConfigFile.Load(directory.FullName);
channelSeenByProject = config?.Channel;
sdkVersionSeenByProject = config?.SdkVersion;
var modulesDir = Directory.CreateDirectory(Path.Combine(directory.FullName, LanguageInfo.GeneratedFolderName));
File.WriteAllText(Path.Combine(modulesDir.FullName, "aspire.mts"), "// generated sdk");
return Task.FromResult(true);
}));
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
var result = command.Parse("new aspire-ts-starter --name TestApp --output ./output --channel daily --localhost-tld false");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
Assert.True(buildAndGenerateCalled);
Assert.Equal("daily", channelSeenByProject);
Assert.Equal(VersionHelper.GetDefaultSdkVersion(), sdkVersionSeenByProject);
Assert.True(File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, "output", LanguageInfo.GeneratedFolderName, "aspire.mts")));
}
[Fact]
public async Task NewCommandWithTypeScriptStarterReturnsFailedToBuildArtifactsWhenSdkGenerationFails()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var interactionService = new TestInteractionService();
var services = CreateServiceCollection(workspace, options =>
{
options.DotNetCliRunnerFactory = _ => new TestDotNetCliRunner
{
SearchPackagesAsyncCallback = (dir, query, exactMatch, prerelease, take, skip, nugetSource, useCache, runnerOptions, cancellationToken) =>
{
var package = new NuGetPackage
{
Id = "Aspire.ProjectTemplates",
Source = "nuget",
Version = "9.2.0"
};
return (0, new NuGetPackage[] { package });
}
};
options.PackagingServiceFactory = _ => new TestPackagingService
{
GetChannelsAsyncCallback = cancellationToken =>
{
var dailyCache = new FakeNuGetPackageCache
{
GetTemplatePackagesAsyncCallback = (dir, prerelease, nugetConfig, ct) =>
{
var package = new NuGetPackage
{
Id = "Aspire.ProjectTemplates",
Source = "nuget",
Version = "9.2.0"
};
return Task.FromResult<IEnumerable<NuGetPackage>>([package]);
}
};
var dailyChannel = PackageChannel.CreateExplicitChannel("daily", PackageChannelQuality.Both, [], dailyCache, new TestFeatures(), NullLogger.Instance);
return Task.FromResult<IEnumerable<PackageChannel>>([dailyChannel]);
}
};
});
services.AddSingleton<IInteractionService>(interactionService);
services.AddSingleton<IAppHostProjectFactory>(new TestTypeScriptStarterProjectFactory((directory, cancellationToken, _) => Task.FromResult(false)));
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
var result = command.Parse("new aspire-ts-starter --name TestApp --output ./output --channel daily --localhost-tld false");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.FailedToBuildArtifacts, exitCode);
Assert.Collection(interactionService.DisplayedErrors,
error => Assert.Equal("Automatic 'aspire restore' failed for the new TypeScript starter project. Run 'aspire restore' in the project directory for more details.", error));
}
[Fact]
public async Task NewCommandWithTypeScriptStarterAndSourceOverridePersistsSourceAndPlumbsOverride()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var sourceOverride = Path.Combine(workspace.WorkspaceRoot.FullName, "source-feed");
Directory.CreateDirectory(sourceOverride);
// A local --source directory is enumerated directly rather than searched, so the template
// package has to exist on disk for version resolution to succeed.
File.WriteAllText(Path.Combine(sourceOverride, "Aspire.ProjectTemplates.9.2.0.nupkg"), string.Empty);
TestInteractionService? interactionService = null;
var services = CreateServiceCollection(workspace, options =>
{
options.InteractionServiceFactory = _ => interactionService = new TestInteractionService();
options.DotNetCliRunnerFactory = _ => new TestDotNetCliRunner
{
SearchPackagesAsyncCallback = (dir, query, exactMatch, prerelease, take, skip, nugetSource, useCache, runnerOptions, cancellationToken) =>
{
var package = new NuGetPackage { Id = "Aspire.ProjectTemplates", Source = "nuget", Version = "9.2.0" };
return (0, new NuGetPackage[] { package });
}
};
options.PackagingServiceFactory = _ => new TestPackagingService
{
GetChannelsAsyncCallback = cancellationToken =>
{
var dailyCache = new FakeNuGetPackageCache
{
GetTemplatePackagesAsyncCallback = (dir, prerelease, nugetConfig, ct) =>
{
var package = new NuGetPackage { Id = "Aspire.ProjectTemplates", Source = "nuget", Version = "9.2.0" };
return Task.FromResult<IEnumerable<NuGetPackage>>([package]);
}
};
var dailyChannel = PackageChannel.CreateExplicitChannel("daily", PackageChannelQuality.Both, [], dailyCache, new TestFeatures(), NullLogger.Instance);
return Task.FromResult<IEnumerable<PackageChannel>>([dailyChannel]);
}
};
});
var projectFactory = new TestTypeScriptStarterProjectFactory((directory, cancellationToken, _) =>
{
var modulesDir = Directory.CreateDirectory(Path.Combine(directory.FullName, ".aspire", "modules"));
File.WriteAllText(Path.Combine(modulesDir.FullName, "aspire.ts"), "// generated sdk");
return Task.FromResult(true);
});
services.AddSingleton<IAppHostProjectFactory>(projectFactory);
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
var result = command.Parse($"new aspire-ts-starter --name TestApp --output ./output --channel daily --localhost-tld false --source \"{sourceOverride}\"");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
Assert.Equal(sourceOverride, projectFactory.Project.LastPackageSourceOverride);
AssertSourceOverrideNuGetConfig(Path.Combine(workspace.WorkspaceRoot.FullName, "output"), sourceOverride);
Assert.NotNull(interactionService);
Assert.DoesNotContain(
interactionService!.DisplayedMessages,
entry => entry.Message == TemplatingStrings.SourceOverrideNotPersistedWarning);
}
[Fact]
public async Task NewCommandWithDotNetTemplateAndSourceOverridePersistsSourceForLaterRestore()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
const string sourceOverride = "https://proxy.example/v3/index.json";
var services = CreateServiceCollection(workspace, options =>
{
options.DotNetCliRunnerFactory = _ =>
{
var runner = CreateTestRunnerWithStandardPackages();
runner.InstallTemplateAsyncCallback = (packageName, version, nugetConfigFile, nugetSource, force, invocationOptions, cancellationToken) =>
{
Assert.NotNull(nugetConfigFile);
var document = XDocument.Load(nugetConfigFile.FullName);
var installPackageSources = document.Root!
.Element("packageSources")!
.Elements("add")
.Select(element => (string)element.Attribute("value")!)
.ToArray();
Assert.Equal([sourceOverride], installPackageSources);
return (0, version);
};
runner.NewProjectAsyncCallback = (templateName, projectName, outputPath, invocationOptions, cancellationToken) =>
{
return 0;
};
return runner;
};
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
var result = command.Parse($"new aspire-starter --name TestApp --output ./output --source {sourceOverride} --use-redis-cache --test-framework None");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
AssertSourceOverrideNuGetConfig(Path.Combine(workspace.WorkspaceRoot.FullName, "output"), sourceOverride);
}
[Fact]
public async Task NewCommandWithTypeScriptStarterAndFailedRestoreDoesNotWarnAboutSourceOverride()
{
// The warning is only meaningful when the scaffold succeeded — surfacing it on a failed
// restore would just add noise behind a more prominent error. Pin that the starter path
// mirrors the empty-template path here.
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var sourceOverride = Path.Combine(workspace.WorkspaceRoot.FullName, "source-feed");
Directory.CreateDirectory(sourceOverride);
// A local --source directory is enumerated directly rather than searched, so the template
// package has to exist on disk for version resolution to succeed.
File.WriteAllText(Path.Combine(sourceOverride, "Aspire.ProjectTemplates.9.2.0.nupkg"), string.Empty);
TestInteractionService? interactionService = null;
var services = CreateServiceCollection(workspace, options =>
{
options.InteractionServiceFactory = _ => interactionService = new TestInteractionService();
options.DotNetCliRunnerFactory = _ => new TestDotNetCliRunner
{
SearchPackagesAsyncCallback = (dir, query, exactMatch, prerelease, take, skip, nugetSource, useCache, runnerOptions, cancellationToken) =>
{
var package = new NuGetPackage { Id = "Aspire.ProjectTemplates", Source = "nuget", Version = "9.2.0" };
return (0, new NuGetPackage[] { package });
}
};
options.PackagingServiceFactory = _ => new TestPackagingService
{
GetChannelsAsyncCallback = cancellationToken =>
{
var dailyCache = new FakeNuGetPackageCache
{
GetTemplatePackagesAsyncCallback = (dir, prerelease, nugetConfig, ct) =>
{
var package = new NuGetPackage { Id = "Aspire.ProjectTemplates", Source = "nuget", Version = "9.2.0" };
return Task.FromResult<IEnumerable<NuGetPackage>>([package]);
}
};
var dailyChannel = PackageChannel.CreateExplicitChannel("daily", PackageChannelQuality.Both, [], dailyCache, new TestFeatures(), NullLogger.Instance);
return Task.FromResult<IEnumerable<PackageChannel>>([dailyChannel]);
}
};
});
services.AddSingleton<IAppHostProjectFactory>(new TestTypeScriptStarterProjectFactory((directory, cancellationToken, _) => Task.FromResult(false)));
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
var result = command.Parse($"new aspire-ts-starter --name TestApp --output ./output --channel daily --localhost-tld false --source {sourceOverride}");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.FailedToBuildArtifacts, exitCode);
Assert.NotNull(interactionService);
Assert.DoesNotContain(
interactionService!.DisplayedMessages,
entry => entry.Message == TemplatingStrings.SourceOverrideNotPersistedWarning);
}
[Fact]
public async Task NewCommandNonInteractiveDoesNotPrompt()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var services = CreateServiceCollection(workspace, options =>
{
// Configure non-interactive host environment
options.CliHostEnvironmentFactory = (sp) =>
{
var configuration = sp.GetRequiredService<Microsoft.Extensions.Configuration.IConfiguration>();
return new CliHostEnvironment(configuration, nonInteractive: true);
};
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<NewCommand>();
var result = command.Parse("new aspire-empty --name TestApp --output ./output");
// Before the fix, this would throw InvalidOperationException with
// "Interactive input is not supported in this environment" because
// GetTemplates() did not pass the nonInteractive flag, causing
// the template to try to prompt for options.
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
}
[Fact]
public async Task NewCommandNonInteractive_WithSkillLocationsNone_DoesNotInstallAgentSkills()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var services = CreateServiceCollection(workspace, options =>
{
options.CliHostEnvironmentFactory = (sp) =>
{
var configuration = sp.GetRequiredService<Microsoft.Extensions.Configuration.IConfiguration>();
return new CliHostEnvironment(configuration, nonInteractive: true);
};
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<NewCommand>();
var result = command.Parse("new aspire-empty --name TestApp --output ./output --skill-locations none");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
var outputDir = Path.Combine(workspace.WorkspaceRoot.FullName, "output");
var agentsDir = Path.Combine(outputDir, ".agents", "skills");
Assert.False(Directory.Exists(agentsDir), $"Expected no agents/skills directory but found {agentsDir}");
}
[Fact]
public async Task NewCommandNonInteractive_WithSkillLocationsAndSkills_InstallsOnlySpecifiedSkills()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var services = CreateServiceCollection(workspace, options =>
{
options.CliHostEnvironmentFactory = (sp) =>
{
var configuration = sp.GetRequiredService<Microsoft.Extensions.Configuration.IConfiguration>();
return new CliHostEnvironment(configuration, nonInteractive: true);
};
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<NewCommand>();
var result = command.Parse($"new aspire-empty --name TestApp --output ./output --skill-locations standard --skills {CommonAgentApplicators.AspireSkillName}");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
var outputDir = Path.Combine(workspace.WorkspaceRoot.FullName, "output");
var aspireSkillPath = Path.Combine(outputDir, ".agents", "skills", CommonAgentApplicators.AspireSkillName, "SKILL.md");
Assert.True(File.Exists(aspireSkillPath), $"Expected aspire skill file at {aspireSkillPath}");
var aspireifySkillPath = Path.Combine(outputDir, ".agents", "skills", CommonAgentApplicators.AspireifySkillName);
Assert.False(Directory.Exists(aspireifySkillPath), $"Expected no aspireify skill directory but found {aspireifySkillPath}");
}
[Fact]
public async Task NewCommandNonInteractiveWithoutTemplate_DisplaysErrorWithAvailableTemplates()
{
TestInteractionService? testInteractionService = null;
string? availableTemplatesMessage = null;
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var services = CreateServiceCollection(workspace, options =>
{
options.CliHostEnvironmentFactory = (sp) =>
{
var configuration = sp.GetRequiredService<Microsoft.Extensions.Configuration.IConfiguration>();
return new CliHostEnvironment(configuration, nonInteractive: true);
};
options.InteractionServiceFactory = (sp) =>
{
testInteractionService = new TestInteractionService
{
DisplaySubtleMessageCallback = message => availableTemplatesMessage = message
};
return testInteractionService;
};
options.FeatureFlagsFactory = _ =>
{
var features = new TestFeatures();
features.SetFeature(KnownFeatures.ExperimentalPolyglotJava, true);
return features;
};
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<NewCommand>();
var result = command.Parse("new");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.MissingRequiredArgument, exitCode);
Assert.NotNull(testInteractionService);
Assert.Contains(testInteractionService.DisplayedErrors,
e => string.Equals(e, NewCommandStrings.NonInteractiveTemplateRequired, StringComparison.Ordinal));
Assert.NotNull(availableTemplatesMessage);
Assert.Contains(KnownTemplateId.TypeScriptEmptyAppHost, availableTemplatesMessage);
Assert.Contains(KnownTemplateId.JavaEmptyAppHost, availableTemplatesMessage);
}
[Fact]
public async Task NewCommandNonInteractiveUsesDefaultNameWhenNotProvided()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
string? capturedProjectName = null;
string? capturedOutputPath = null;
var services = CreateServiceCollection(workspace, options =>
{
options.CliHostEnvironmentFactory = (sp) =>
{
var configuration = sp.GetRequiredService<Microsoft.Extensions.Configuration.IConfiguration>();
return new CliHostEnvironment(configuration, nonInteractive: true);
};
options.DotNetCliRunnerFactory = (sp) =>
{
var runner = CreateTestRunnerWithStandardPackages();
runner.NewProjectAsyncCallback = (templateName, projectName, outputPath, invocationOptions, ct) =>
{
capturedProjectName = projectName;
capturedOutputPath = outputPath;
return 0;
};
return runner;
};
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<NewCommand>();
// Neither --name nor --output is provided, so both use their defaults
var result = command.Parse("new aspire-starter --use-redis-cache --test-framework None");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
// The default project name is derived from the template name
Assert.Equal("aspire-starter", capturedProjectName);
// The default output path is derived from the template name
Assert.Equal(Path.Combine(workspace.WorkspaceRoot.FullName, "aspire-starter"), capturedOutputPath);
}
[Fact]
public async Task NewCommandNonInteractiveWithAllOptions_Succeeds()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
string? capturedProjectName = null;
string? capturedOutputPath = null;
var services = CreateServiceCollection(workspace, options =>
{
options.CliHostEnvironmentFactory = (sp) =>
{
var configuration = sp.GetRequiredService<Microsoft.Extensions.Configuration.IConfiguration>();
return new CliHostEnvironment(configuration, nonInteractive: true);
};
options.DotNetCliRunnerFactory = (sp) =>
{
var runner = CreateTestRunnerWithStandardPackages();
runner.NewProjectAsyncCallback = (templateName, projectName, outputPath, invocationOptions, ct) =>
{
capturedProjectName = projectName;
capturedOutputPath = outputPath;
return 0;
};
return runner;
};
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<NewCommand>();
var result = command.Parse($"new aspire-starter --name MyProject --output {Path.Combine(workspace.WorkspaceRoot.FullName, "my-project")} --use-redis-cache --test-framework None");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
Assert.Equal("MyProject", capturedProjectName);
Assert.NotNull(capturedOutputPath);
Assert.Contains("my-project", capturedOutputPath);
// Agent init runs by default after project creation
var skillPath = Path.Combine(capturedOutputPath, ".agents", "skills", "aspire", "SKILL.md");
Assert.True(File.Exists(skillPath));
}
[Fact]
public async Task NewCommandNonInteractiveWithAllOptions_SuppressAgentInitTrue_SkipsAgentInit()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var services = CreateServiceCollection(workspace, options =>
{
options.CliHostEnvironmentFactory = (sp) =>
{
var configuration = sp.GetRequiredService<Microsoft.Extensions.Configuration.IConfiguration>();
return new CliHostEnvironment(configuration, nonInteractive: true);
};
options.DotNetCliRunnerFactory = (sp) =>
{
var runner = CreateTestRunnerWithStandardPackages();
runner.NewProjectAsyncCallback = (templateName, projectName, outputPath, invocationOptions, ct) =>
{
return 0;
};
return runner;
};
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<NewCommand>();
var outputDir = Path.Combine(workspace.WorkspaceRoot.FullName, "output");
var result = command.Parse($"new aspire-starter --name MyProject --output {outputDir} --use-redis-cache --test-framework None --suppress-agent-init");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
// Agent init should not have run — no skill files should exist
var skillPath = Path.Combine(outputDir, ".agents", "skills", "aspire", "SKILL.md");
Assert.False(File.Exists(skillPath));
}
[Fact]
public async Task NewCommand_WhenCSharpTemplateApplyFails_DisplaysCreationErrorMessage()
{
TestInteractionService? testInteractionService = null;
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var services = CreateServiceCollection(workspace, options =>
{
options.InteractionServiceFactory = (sp) =>
{
testInteractionService = new TestInteractionService();
return testInteractionService;
};
options.NewCommandPrompterFactory = (sp) =>
{
var interactionService = sp.GetRequiredService<IInteractionService>();
return new TestNewCommandPrompter(interactionService);
};
options.DotNetCliRunnerFactory = (sp) =>
{
var runner = CreateTestRunnerWithStandardPackages();
runner.NewProjectAsyncCallback = (templateName, projectName, outputPath, invocationOptions, ct) =>
{
return 1; // Simulate failure
};
return runner;
};
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<NewCommand>();
var result = command.Parse("new aspire-starter --use-redis-cache --test-framework None");
var exitCode = await result.InvokeAsync().DefaultTimeout();
var executionContext = provider.GetRequiredService<CliExecutionContext>();
var expectedMessage = string.Format(CultureInfo.CurrentCulture, TemplatingStrings.ProjectCreationFailed, 1, executionContext.LogFilePath);
Assert.NotEqual(0, exitCode);
Assert.NotNull(testInteractionService);
Assert.Contains(expectedMessage, testInteractionService.DisplayedErrors);
}
[Fact]
public async Task NewCommand_WhenTypeScriptTemplateApplyFails_ReturnsNonZeroExitCode()
{
TestInteractionService? testInteractionService = null;
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var services = CreateServiceCollection(workspace, options =>
{
options.InteractionServiceFactory = (sp) =>
{
testInteractionService = new TestInteractionService();
return testInteractionService;
};
});
services.AddSingleton<IScaffoldingService>(new TestScaffoldingService
{
ScaffoldAsyncCallback = (context, cancellationToken) =>
{
return Task.FromResult(false); // Simulate failure for TypeScript template
}
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<NewCommand>();
var result = command.Parse("new aspire-ts-empty --name TestApp --output ./output");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.NotEqual(0, exitCode);
Assert.NotNull(testInteractionService);
}
[Fact]
public async Task NewCommandInExtensionModeAppendsProjectNameToOutputPath()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
string? capturedOutputPath = null;
var services = CreateServiceCollection(workspace, options =>
{
options.CliHostEnvironmentFactory = _ => TestHelpers.CreateInteractiveHostEnvironment();
options.InteractionServiceFactory = sp => new TestExtensionInteractionService(sp);
options.ExtensionBackchannelFactory = _ => new TestExtensionBackchannel
{
HasCapabilityAsyncCallback = (c, _) => Task.FromResult(c is "baseline.v1"),
};
options.NewCommandPrompterFactory = (sp) =>
{
var interactionService = sp.GetRequiredService<IInteractionService>();
var prompter = new TestNewCommandPrompter(interactionService);
prompter.PromptForProjectNameCallback = (_) => "MyFirstApp";
// Simulate the user picking a parent folder (not named after the project)
prompter.PromptForOutputPathCallback = (_) =>
Path.Combine(workspace.WorkspaceRoot.FullName, "source");
return prompter;
};
options.DotNetCliRunnerFactory = (sp) =>
{
var runner = CreateTestRunnerWithStandardPackages();
runner.InstallTemplateAsyncCallback = (packageName, version, nugetConfigFile, nugetSource, force, invocationOptions, ct) =>
{
return (0, version);
};
runner.NewProjectAsyncCallback = (templateName, projectName, outputPath, invocationOptions, ct) =>
{
capturedOutputPath = outputPath;
return 0;
};
return runner;
};
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
var result = command.Parse("new aspire-starter --use-redis-cache --test-framework None");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
Assert.NotNull(capturedOutputPath);
// Output path should have the project name appended as a subdirectory
var expectedPath = Path.Combine(workspace.WorkspaceRoot.FullName, "source", "MyFirstApp");
Assert.Equal(expectedPath, capturedOutputPath);
}
[Fact]
public async Task NewCommandInExtensionModeDoesNotDoubleAppendProjectName()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
string? capturedOutputPath = null;
var services = CreateServiceCollection(workspace, options =>
{
options.CliHostEnvironmentFactory = _ => TestHelpers.CreateInteractiveHostEnvironment();
options.InteractionServiceFactory = sp => new TestExtensionInteractionService(sp);
options.ExtensionBackchannelFactory = _ => new TestExtensionBackchannel
{
HasCapabilityAsyncCallback = (c, _) => Task.FromResult(c is "baseline.v1"),
};
options.NewCommandPrompterFactory = (sp) =>
{
var interactionService = sp.GetRequiredService<IInteractionService>();
var prompter = new TestNewCommandPrompter(interactionService);
prompter.PromptForProjectNameCallback = (_) => "MyFirstApp";
// Simulate the user picking a folder already named after the project
prompter.PromptForOutputPathCallback = (_) =>
Path.Combine(workspace.WorkspaceRoot.FullName, "source", "MyFirstApp");
return prompter;
};
options.DotNetCliRunnerFactory = (sp) =>
{
var runner = CreateTestRunnerWithStandardPackages();
runner.InstallTemplateAsyncCallback = (packageName, version, nugetConfigFile, nugetSource, force, invocationOptions, ct) =>
{
return (0, version);
};
runner.NewProjectAsyncCallback = (templateName, projectName, outputPath, invocationOptions, ct) =>
{
capturedOutputPath = outputPath;
return 0;
};
return runner;
};
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
var result = command.Parse("new aspire-starter --use-redis-cache --test-framework None");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
Assert.NotNull(capturedOutputPath);
// Output path should NOT have the project name double-appended
var expectedPath = Path.Combine(workspace.WorkspaceRoot.FullName, "source", "MyFirstApp");
Assert.Equal(expectedPath, capturedOutputPath);
}
[Fact]
public async Task NewCommandInConsoleModeDoesNotAppendProjectName()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
string? capturedOutputPath = null;
var services = CreateServiceCollection(workspace, options =>
{
options.CliHostEnvironmentFactory = _ => TestHelpers.CreateInteractiveHostEnvironment();
// Use TestInteractionService (not ExtensionInteractionService) to stay in console/non-extension mode
options.InteractionServiceFactory = _ => new TestInteractionService();
// Default InteractionServiceFactory creates ConsoleInteractionService (not extension mode)
options.NewCommandPrompterFactory = (sp) =>
{
var interactionService = sp.GetRequiredService<IInteractionService>();
var prompter = new TestNewCommandPrompter(interactionService);
prompter.PromptForProjectNameCallback = (_) => "MyFirstApp";
// Simulate user accepting default path or selecting parent folder
prompter.PromptForOutputPathCallback = (_) =>
Path.Combine(workspace.WorkspaceRoot.FullName, "source");
return prompter;
};
options.DotNetCliRunnerFactory = (sp) =>
{
var runner = CreateTestRunnerWithStandardPackages();
runner.InstallTemplateAsyncCallback = (packageName, version, nugetConfigFile, nugetSource, force, invocationOptions, ct) =>
{
return (0, version);
};
runner.NewProjectAsyncCallback = (templateName, projectName, outputPath, invocationOptions, ct) =>
{
capturedOutputPath = outputPath;
return 0;
};
return runner;
};
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
var result = command.Parse("new aspire-starter --use-redis-cache --test-framework None");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
Assert.NotNull(capturedOutputPath);
// In console mode, the output path should NOT have project name appended
var expectedPath = Path.Combine(workspace.WorkspaceRoot.FullName, "source");
Assert.Equal(expectedPath, capturedOutputPath);
}
[Fact]
public async Task NewCommandInExtensionModeHandlesTrailingDirectorySeparator()
{
const string projectName = "MyFirstApp";
async Task AssertOutputPathAsync(Func<string, string> selectedPathFactory, Func<string, string> expectedPathFactory)
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
string? capturedOutputPath = null;
var services = CreateServiceCollection(workspace, options =>
{
options.CliHostEnvironmentFactory = _ => TestHelpers.CreateInteractiveHostEnvironment();
options.InteractionServiceFactory = sp => new TestExtensionInteractionService(sp);
options.ExtensionBackchannelFactory = _ => new TestExtensionBackchannel
{
HasCapabilityAsyncCallback = (c, _) => Task.FromResult(c is "baseline.v1"),
};
options.NewCommandPrompterFactory = (sp) =>
{
var interactionService = sp.GetRequiredService<IInteractionService>();
var prompter = new TestNewCommandPrompter(interactionService);
prompter.PromptForProjectNameCallback = (_) => projectName;
prompter.PromptForOutputPathCallback = (_) =>
selectedPathFactory(workspace.WorkspaceRoot.FullName);
return prompter;
};
options.DotNetCliRunnerFactory = (sp) =>
{
var runner = CreateTestRunnerWithStandardPackages();
runner.InstallTemplateAsyncCallback = (packageName, version, nugetConfigFile, nugetSource, force, invocationOptions, ct) =>
{
return (0, version);
};
runner.NewProjectAsyncCallback = (templateName, pName, outputPath, invocationOptions, ct) =>
{
capturedOutputPath = outputPath;
return 0;
};
return runner;
};
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
var result = command.Parse("new aspire-starter --use-redis-cache --test-framework None");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
Assert.NotNull(capturedOutputPath);
Assert.Equal(expectedPathFactory(workspace.WorkspaceRoot.FullName), capturedOutputPath);
}
// Trailing separator on a parent folder should still append the project name once.
await AssertOutputPathAsync(
workspaceRoot => Path.Combine(workspaceRoot, "source") + Path.DirectorySeparatorChar,
workspaceRoot => Path.Combine(workspaceRoot, "source", projectName));
// Trailing separator on a folder already named after the project should not double-append.
await AssertOutputPathAsync(
workspaceRoot => Path.Combine(workspaceRoot, projectName) + Path.DirectorySeparatorChar,
workspaceRoot => Path.Combine(workspaceRoot, projectName));
}
[Fact]
public async Task NewCommandInExtensionModeAppendsProjectNameToCliTemplateOutputPath()
{
const string projectName = "MyFirstApp";
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
DirectoryInfo? capturedTargetDirectory = null;
var services = CreateServiceCollection(workspace, options =>
{
options.CliHostEnvironmentFactory = _ => TestHelpers.CreateInteractiveHostEnvironment();
options.InteractionServiceFactory = sp => new TestExtensionInteractionService(sp);
options.ExtensionBackchannelFactory = _ => new TestExtensionBackchannel
{
HasCapabilityAsyncCallback = (c, _) => Task.FromResult(c is "baseline.v1"),
};
options.NewCommandPrompterFactory = (sp) =>
{
var interactionService = sp.GetRequiredService<IInteractionService>();
var prompter = new TestNewCommandPrompter(interactionService);
prompter.PromptForOutputPathCallback = (_) =>
Path.Combine(workspace.WorkspaceRoot.FullName, "source");
return prompter;
};
});
services.AddSingleton<IScaffoldingService>(new TestScaffoldingService
{
ScaffoldAsyncCallback = (context, cancellationToken) =>
{
capturedTargetDirectory = context.TargetDirectory;
File.WriteAllText(Path.Combine(context.TargetDirectory.FullName, "apphost.ts"), "// test apphost");
return Task.FromResult(true);
}
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
var result = command.Parse($"new {KnownTemplateId.TypeScriptEmptyAppHost} --name {projectName} --version 9.2.0 --localhost-tld false");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
Assert.NotNull(capturedTargetDirectory);
var expectedPath = Path.Combine(workspace.WorkspaceRoot.FullName, "source", projectName);
Assert.Equal(expectedPath, capturedTargetDirectory.FullName);
Assert.True(File.Exists(Path.Combine(expectedPath, "apphost.ts")));
}
[Fact]
public async Task NewCommandInExtensionModeValidatesAdjustedCliTemplateOutputPath()
{
const string projectName = "MyFirstApp";
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var selectedParent = workspace.CreateDirectory("source");
File.WriteAllText(Path.Combine(selectedParent.FullName, "existing.txt"), "existing content");
DirectoryInfo? capturedTargetDirectory = null;
var selectedParentWasValidated = false;
var services = CreateServiceCollection(workspace, options =>
{
options.CliHostEnvironmentFactory = _ => TestHelpers.CreateInteractiveHostEnvironment();
options.InteractionServiceFactory = sp => new TestExtensionInteractionService(sp);
options.ExtensionBackchannelFactory = _ => new TestExtensionBackchannel
{
HasCapabilityAsyncCallback = (c, _) => Task.FromResult(c is "baseline.v1"),
};
options.NewCommandPrompterFactory = (sp) =>
{
var interactionService = sp.GetRequiredService<IInteractionService>();
var prompter = new TestNewCommandPrompter(interactionService);
prompter.PromptForOutputPathWithValidatorCallback = (_, validator) =>
{
Assert.NotNull(validator);
var validationResult = validator(selectedParent.FullName);
selectedParentWasValidated = true;
Assert.True(validationResult.Successful, validationResult.Message);
return selectedParent.FullName;
};
return prompter;
};
});
services.AddSingleton<IScaffoldingService>(new TestScaffoldingService
{
ScaffoldAsyncCallback = (context, cancellationToken) =>
{
capturedTargetDirectory = context.TargetDirectory;
File.WriteAllText(Path.Combine(context.TargetDirectory.FullName, "apphost.ts"), "// test apphost");
return Task.FromResult(true);
}
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
var result = command.Parse($"new {KnownTemplateId.TypeScriptEmptyAppHost} --name {projectName} --version 9.2.0 --localhost-tld false");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
Assert.True(selectedParentWasValidated);
Assert.NotNull(capturedTargetDirectory);
var expectedPath = Path.Combine(selectedParent.FullName, projectName);
Assert.Equal(expectedPath, capturedTargetDirectory.FullName);
Assert.True(File.Exists(Path.Combine(expectedPath, "apphost.ts")));
}
[Fact]
public async Task NewCommandInExtensionModeRetriesFolderPickerAfterProjectSubdirectoryCollision()
{
const string projectName = "aspire-starter";
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var collidingParent = workspace.CreateDirectory("colliding-parent");
var collidingProject = Directory.CreateDirectory(Path.Combine(collidingParent.FullName, projectName));
File.WriteAllText(Path.Combine(collidingProject.FullName, "existing.txt"), "existing content");
var validParent = workspace.CreateDirectory("valid-parent");
var selectedParents = new Queue<string?>([collidingParent.FullName, validParent.FullName]);
var displayedErrors = new List<string>();
var promptCount = 0;
string? capturedOutputPath = null;
var backchannel = new TestExtensionBackchannel
{
HasCapabilityAsyncCallback = (capability, _) => Task.FromResult(
capability is KnownCapabilities.Baseline or KnownCapabilities.FilePickers),
PromptForFilePathAsyncCallback = (_, _, _) =>
{
promptCount++;
return Task.FromResult(selectedParents.Dequeue());
},
DisplayErrorAsyncCallback = error =>
{
displayedErrors.Add(error);
return Task.CompletedTask;
}
};
var services = CreateServiceCollection(workspace, options =>
{
options.CliHostEnvironmentFactory = _ => TestHelpers.CreateInteractiveHostEnvironment();
options.ExtensionBackchannelFactory = _ => backchannel;
options.InteractionServiceFactory = sp =>
{
var consoleInteractionService = new ConsoleInteractionService(
sp.GetRequiredService<ConsoleEnvironment>(),
sp.GetRequiredService<CliExecutionContext>(),
sp.GetRequiredService<ICliHostEnvironment>(),
sp.GetRequiredService<IProcessPathProvider>(),
NullLoggerFactory.Instance,
sp.GetRequiredService<ConsoleLogBufferContext>());
return new ExtensionInteractionService(
consoleInteractionService,
backchannel,
extensionPromptEnabled: true,
logger: NullLogger<ExtensionInteractionService>.Instance);
};
options.DotNetCliRunnerFactory = _ =>
{
var runner = CreateTestRunnerWithStandardPackages();
runner.InstallTemplateAsyncCallback = (_, version, _, _, _, _, _) => (0, version);
runner.NewProjectAsyncCallback = (_, _, outputPath, _, _) =>
{
capturedOutputPath = outputPath;
return 0;
};
return runner;
};
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
var result = command.Parse(
$"new aspire-starter --name {projectName} --version 9.2.0 --use-redis-cache --test-framework None --suppress-agent-init");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
Assert.Equal(2, promptCount);
Assert.Equal(Path.Combine(validParent.FullName, projectName), capturedOutputPath);
var expectedError = string.Format(
CultureInfo.CurrentCulture,
NewCommandStrings.OutputDirectoryNotEmptyInteractive,
collidingProject.FullName);
Assert.Equal([expectedError], displayedErrors);
}
[Fact]
public async Task NewCommandInExtensionModePromptsBeforeFolderPickerForCliTemplateSubdirectory()
{
const string projectName = "MyFirstApp";
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var selectedParent = workspace.CreateDirectory("source");
DirectoryInfo? capturedTargetDirectory = null;
var selectionPrompted = false;
const string expectedPrompt = "Where do you want the project to be created?";
var expectedSubdirectoryChoice = $"In a subdirectory named '{projectName}' in the selected folder";
const string expectedDirectChoice = "Directly in the selected folder";
var services = CreateServiceCollection(workspace, options =>
{
options.CliHostEnvironmentFactory = _ => TestHelpers.CreateInteractiveHostEnvironment();
options.InteractionServiceFactory = sp => new TestExtensionInteractionService(sp)
{
ConfirmCallback = (_, defaultValue) => defaultValue,
SelectionCallback = (promptText, choices) =>
{
Assert.Equal(expectedPrompt, promptText);
Assert.Equal([expectedSubdirectoryChoice, expectedDirectChoice], choices);
selectionPrompted = true;
return expectedSubdirectoryChoice;
}
};
options.ExtensionBackchannelFactory = _ => new TestExtensionBackchannel
{
HasCapabilityAsyncCallback = (c, _) => Task.FromResult(c is "baseline.v1"),
};
options.NewCommandPrompterFactory = (sp) =>
{
var interactionService = sp.GetRequiredService<IInteractionService>();
var prompter = new TestNewCommandPrompter(interactionService);
prompter.PromptForOutputPathCallback = (_) =>
{
Assert.True(selectionPrompted);
return selectedParent.FullName;
};
return prompter;
};
});
services.AddSingleton<IScaffoldingService>(new TestScaffoldingService
{
ScaffoldAsyncCallback = (context, cancellationToken) =>
{
capturedTargetDirectory = context.TargetDirectory;
File.WriteAllText(Path.Combine(context.TargetDirectory.FullName, "apphost.ts"), "// test apphost");
return Task.FromResult(true);
}
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
var result = command.Parse($"new {KnownTemplateId.TypeScriptEmptyAppHost} --name {projectName} --version 9.2.0 --localhost-tld false");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
Assert.True(selectionPrompted);
Assert.NotNull(capturedTargetDirectory);
var expectedPath = Path.Combine(selectedParent.FullName, projectName);
Assert.Equal(expectedPath, capturedTargetDirectory.FullName);
Assert.True(File.Exists(Path.Combine(expectedPath, "apphost.ts")));
}
[Fact]
public async Task NewCommandInExtensionModeUsesSelectedCliTemplateOutputPathWhenSubdirectoryDeclined()
{
const string projectName = "MyFirstApp";
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var selectedOutputPath = workspace.CreateDirectory("source");
DirectoryInfo? capturedTargetDirectory = null;
var selectionPrompted = false;
const string expectedPrompt = "Where do you want the project to be created?";
var expectedSubdirectoryChoice = $"In a subdirectory named '{projectName}' in the selected folder";
const string expectedDirectChoice = "Directly in the selected folder";
var services = CreateServiceCollection(workspace, options =>
{
options.CliHostEnvironmentFactory = _ => TestHelpers.CreateInteractiveHostEnvironment();
options.InteractionServiceFactory = sp => new TestExtensionInteractionService(sp)
{
ConfirmCallback = (_, defaultValue) => defaultValue,
SelectionCallback = (promptText, choices) =>
{
Assert.Equal(expectedPrompt, promptText);
Assert.Equal([expectedSubdirectoryChoice, expectedDirectChoice], choices);
selectionPrompted = true;
return expectedDirectChoice;
}
};
options.ExtensionBackchannelFactory = _ => new TestExtensionBackchannel
{
HasCapabilityAsyncCallback = (c, _) => Task.FromResult(c is "baseline.v1"),
};
options.NewCommandPrompterFactory = (sp) =>
{
var interactionService = sp.GetRequiredService<IInteractionService>();
var prompter = new TestNewCommandPrompter(interactionService);
prompter.PromptForOutputPathCallback = (_) =>
selectedOutputPath.FullName;
return prompter;
};
});
services.AddSingleton<IScaffoldingService>(new TestScaffoldingService
{
ScaffoldAsyncCallback = (context, cancellationToken) =>
{
capturedTargetDirectory = context.TargetDirectory;
File.WriteAllText(Path.Combine(context.TargetDirectory.FullName, "apphost.ts"), "// test apphost");
return Task.FromResult(true);
}
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
var result = command.Parse($"new {KnownTemplateId.TypeScriptEmptyAppHost} --name {projectName} --version 9.2.0 --localhost-tld false");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
Assert.True(selectionPrompted);
Assert.NotNull(capturedTargetDirectory);
Assert.Equal(selectedOutputPath.FullName, capturedTargetDirectory.FullName);
Assert.True(File.Exists(Path.Combine(selectedOutputPath.FullName, "apphost.ts")));
Assert.False(File.Exists(Path.Combine(selectedOutputPath.FullName, projectName, "apphost.ts")));
}
[Fact]
public async Task NewCommandInExtensionModeDoesNotDoubleAppendProjectNameToCliTemplateOutputPath()
{
const string projectName = "MyFirstApp";
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
DirectoryInfo? capturedTargetDirectory = null;
var services = CreateServiceCollection(workspace, options =>
{
options.CliHostEnvironmentFactory = _ => TestHelpers.CreateInteractiveHostEnvironment();
options.InteractionServiceFactory = sp => new TestExtensionInteractionService(sp);
options.ExtensionBackchannelFactory = _ => new TestExtensionBackchannel
{
HasCapabilityAsyncCallback = (c, _) => Task.FromResult(c is "baseline.v1"),
};
options.NewCommandPrompterFactory = (sp) =>
{
var interactionService = sp.GetRequiredService<IInteractionService>();
var prompter = new TestNewCommandPrompter(interactionService);
prompter.PromptForOutputPathCallback = (_) =>
Path.Combine(workspace.WorkspaceRoot.FullName, "source", projectName);
return prompter;
};
});
services.AddSingleton<IScaffoldingService>(new TestScaffoldingService
{
ScaffoldAsyncCallback = (context, cancellationToken) =>
{
capturedTargetDirectory = context.TargetDirectory;
File.WriteAllText(Path.Combine(context.TargetDirectory.FullName, "apphost.ts"), "// test apphost");
return Task.FromResult(true);
}
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
var result = command.Parse($"new {KnownTemplateId.TypeScriptEmptyAppHost} --name {projectName} --version 9.2.0 --localhost-tld false");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
Assert.NotNull(capturedTargetDirectory);
var expectedPath = Path.Combine(workspace.WorkspaceRoot.FullName, "source", projectName);
Assert.Equal(expectedPath, capturedTargetDirectory.FullName);
Assert.True(File.Exists(Path.Combine(expectedPath, "apphost.ts")));
}
[Fact]
public async Task NewCommandNonInteractive_SuppressAgentInitTrue_SkipsAgentInit()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var services = CreateServiceCollection(workspace, options =>
{
options.CliHostEnvironmentFactory = (sp) =>
{
var configuration = sp.GetRequiredService<Microsoft.Extensions.Configuration.IConfiguration>();
return new CliHostEnvironment(configuration, nonInteractive: true);
};
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<NewCommand>();
var result = command.Parse("new aspire-empty --name TestApp --output ./output --suppress-agent-init");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
// Agent init should not have run — no skill files should exist
var skillPath = Path.Combine(workspace.WorkspaceRoot.FullName, "output", ".agents", "skills", CommonAgentApplicators.AspireSkillName, "SKILL.md");
Assert.False(File.Exists(skillPath));
}
[Fact]
public async Task NewCommandNonInteractive_SuppressAgentInitFalse_RunsAgentInit()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var services = CreateServiceCollection(workspace, options =>
{
options.CliHostEnvironmentFactory = (sp) =>
{
var configuration = sp.GetRequiredService<Microsoft.Extensions.Configuration.IConfiguration>();
return new CliHostEnvironment(configuration, nonInteractive: true);
};
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<NewCommand>();
var result = command.Parse("new aspire-empty --name TestApp --output ./output --suppress-agent-init=false");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
// Agent init should have run — default skill files should exist
var skillPath = Path.Combine(workspace.WorkspaceRoot.FullName, "output", ".agents", "skills", CommonAgentApplicators.AspireSkillName, "SKILL.md");
Assert.True(File.Exists(skillPath));
var aspireifySkillPath = Path.Combine(workspace.WorkspaceRoot.FullName, "output", ".agents", "skills", CommonAgentApplicators.AspireifySkillName, "SKILL.md");
Assert.False(File.Exists(aspireifySkillPath));
}
[Fact]
public async Task NewCommandNonInteractive_NoSuppressAgentInitOption_DefaultsToRunAgentInit()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var services = CreateServiceCollection(workspace, options =>
{
options.CliHostEnvironmentFactory = (sp) =>
{
var configuration = sp.GetRequiredService<Microsoft.Extensions.Configuration.IConfiguration>();
return new CliHostEnvironment(configuration, nonInteractive: true);
};
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<NewCommand>();
var result = command.Parse("new aspire-empty --name TestApp --output ./output");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
// Default is to run agent init
var skillPath = Path.Combine(workspace.WorkspaceRoot.FullName, "output", ".agents", "skills", CommonAgentApplicators.AspireSkillName, "SKILL.md");
Assert.True(File.Exists(skillPath));
var aspireifySkillPath = Path.Combine(workspace.WorkspaceRoot.FullName, "output", ".agents", "skills", CommonAgentApplicators.AspireifySkillName, "SKILL.md");
Assert.False(File.Exists(aspireifySkillPath));
}
[Fact]
public async Task NewCommandRejectsExplicitOutputToNonEmptyDirectory()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
// Create a non-empty directory at the output path
var existingDir = workspace.CreateDirectory("existing-output");
File.WriteAllText(Path.Combine(existingDir.FullName, "file.txt"), "content");
TestInteractionService? testInteractionService = null;
var services = CreateServiceCollection(workspace, options =>
{
options.InteractionServiceFactory = (sp) =>
{
testInteractionService = new TestInteractionService();
return testInteractionService;
};
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<NewCommand>();
var result = command.Parse($"new aspire-starter --name TestApp --output {existingDir.FullName} --use-redis-cache --test-framework None");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.FailedToCreateNewProject, exitCode);
Assert.NotNull(testInteractionService);
var expectedError = string.Format(CultureInfo.CurrentCulture, NewCommandStrings.OutputDirectoryNotEmptyNonInteractive, existingDir.FullName);
var e = Assert.Single(testInteractionService.DisplayedErrors);
Assert.Equal(expectedError, e);
}
[Fact]
public async Task NewCommandAllowsExplicitOutputToEmptyDirectory()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
// Create an empty directory at the output path
var emptyDir = workspace.CreateDirectory("empty-output");
var services = CreateServiceCollection(workspace);
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<NewCommand>();
var result = command.Parse($"new aspire-starter --name TestApp --output {emptyDir.FullName} --use-redis-cache --test-framework None");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
}
[Fact]
public async Task NewCommandDefaultOutputPathUsesUniqueProjectNameWhenDirectoryExists()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
// Create a non-empty directory matching the default project name (template name)
var existingDir = workspace.CreateDirectory("aspire-starter");
File.WriteAllText(Path.Combine(existingDir.FullName, "file.txt"), "content");
string? capturedDefaultPath = null;
var services = CreateServiceCollection(workspace, options =>
{
options.NewCommandPrompterFactory = (sp) =>
{
var interactionService = sp.GetRequiredService<IInteractionService>();
var prompter = new TestNewCommandPrompter(interactionService);
prompter.PromptForOutputPathCallback = (path) =>
{
capturedDefaultPath = path;
return path;
};
return prompter;
};
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
var result = command.Parse("new aspire-starter --use-redis-cache --test-framework None");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
Assert.Equal("./aspire-starter-2", capturedDefaultPath);
}
[Fact]
public async Task NewCommandRejectsExplicitOutputWithInvalidPathCharacters()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
TestInteractionService? testInteractionService = null;
var services = CreateServiceCollection(workspace, options =>
{
options.InteractionServiceFactory = (sp) =>
{
testInteractionService = new TestInteractionService();
return testInteractionService;
};
});
using var provider = services.BuildServiceProvider();
var invalidPath = "output\0path";
var command = provider.GetRequiredService<NewCommand>();
var result = command.Parse($"new aspire-starter --name TestApp --output {invalidPath} --use-redis-cache --test-framework None");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.FailedToCreateNewProject, exitCode);
Assert.NotNull(testInteractionService);
var expectedError = string.Format(CultureInfo.CurrentCulture, NewCommandStrings.OutputPathContainsInvalidCharacters, invalidPath);
var e = Assert.Single(testInteractionService.DisplayedErrors);
Assert.Equal(expectedError, e);
}
[Fact]
public async Task NewCommandCreatesProjectInCurrentDirectoryWithOutputDot()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
// Create an empty subdirectory to use as the CLI working directory.
// Keep options.WorkingDirectory as the workspace root so test infra
// (.aspire/logs, settings files) doesn't pollute the project dir.
var projectDir = workspace.CreateDirectory("my-project");
string? capturedOutputPath = null;
var services = CreateServiceCollection(workspace, options =>
{
options.CliExecutionContextFactory = _ =>
{
// Use projectDir as the working directory but root the .aspire/*
// directories under the workspace so test infra doesn't pollute the project dir.
return TestExecutionContextHelper.CreateExecutionContext(
projectDir,
identityChannel: PackageChannelNames.Stable);
};
options.DotNetCliRunnerFactory = _ =>
{
var runner = CreateTestRunnerWithStandardPackages();
runner.NewProjectAsyncCallback = (templateName, projectName, outputPath, invocationOptions, ct) =>
{
capturedOutputPath = outputPath;
return 0;
};
return runner;
};
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<NewCommand>();
var result = command.Parse("new aspire-starter --name TestApp --output . --use-redis-cache --test-framework None");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.Success, exitCode);
Assert.Equal(projectDir.FullName, capturedOutputPath);
}
[Fact]
public void OutputPathValidatorRejectsPathWithInvalidCharacters()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var validator = OutputPathHelper.CreateOutputPathValidator(workspace.WorkspaceRoot.FullName);
var invalidPath = "output\0path";
var validationResult = validator(invalidPath);
var expectedMessage = string.Format(CultureInfo.CurrentCulture, NewCommandStrings.OutputPathContainsInvalidCharacters, invalidPath);
Assert.Equal(expectedMessage, validationResult.Message);
}
[Fact]
public async Task NewCommandWhenChannelTemplateSearchFailsDisplaysFriendlyError()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var interactionService = new TestInteractionService();
var services = CreateServiceCollection(workspace, options =>
{
options.InteractionServiceFactory = _ => interactionService;
// Fake cache throws NuGetPackageCacheException to simulate offline / inaccessible feed.
options.PackagingServiceFactory = _ =>
{
var fakeCache = new FakeNuGetPackageCache
{
GetTemplatePackagesAsyncCallback = (_, _, _, _) =>
throw new NuGetPackageCacheException("Package search failed: simulated network failure")
};
var implicitChannel = PackageChannel.CreateImplicitChannel(fakeCache, new TestFeatures(), NullLogger.Instance);
return new TestPackagingService
{
GetChannelsAsyncCallback = _ => Task.FromResult<IEnumerable<PackageChannel>>([implicitChannel])
};
};
options.DotNetCliRunnerFactory = _ =>
{
var runner = new TestDotNetCliRunner();
runner.InstallTemplateAsyncCallback = (_, _, _, _, _, _, _) =>
{
throw new InvalidOperationException("InstallTemplateAsync should not run when channel search fails.");
};
return runner;
};
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
var result = command.Parse("new aspire-starter");
var exitCode = await result.InvokeAsync().DefaultTimeout();
Assert.Equal(CliExitCodes.FailedToCreateNewProject, exitCode);
Assert.Contains(interactionService.DisplayedErrors, e => e.Contains("simulated network failure", StringComparison.Ordinal));
}
}