// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.IO.Compression;
using System.Xml.Linq;
using Aspire.Cli.EndToEnd.Tests.Helpers;
using Hex1b.Automation;
using Xunit;
namespace Aspire.Cli.EndToEnd.Tests;
/// <summary>
/// End-to-end tests for Aspire CLI with Empty AppHost template.
/// Each test class runs as a separate CI job for parallelization.
/// </summary>
public sealed class EmptyAppHostTemplateTests(ITestOutputHelper output)
{
[CaptureWorkspaceOnFailure]
[Fact]
public async Task CreateAndRunEmptyAppHostProject()
{
var repoRoot = CliE2ETestHelpers.GetRepoRoot();
var strategy = CliInstallStrategy.Detect(output.WriteLine);
var workspace = TemporaryWorkspace.Create(output);
using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace);
var counter = new SequenceCounter();
var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken);
await auto.PrepareDockerEnvironmentAsync(counter, workspace);
await auto.InstallAspireCliAsync(strategy, counter);
await auto.AspireNewAsync("AspireEmptyApp", counter, template: AspireTemplate.EmptyAppHost);
// Start the empty AppHost to verify the scaffolded project works
await auto.TypeAsync("cd AspireEmptyApp");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter);
await auto.AspireStartAsync(counter);
await auto.AspireStopAsync(counter);
}
[CaptureWorkspaceOnFailure]
[Fact]
public async Task CreateDotNetTemplateWithSourceOverrideDoesNotContactOtherSources()
{
var repoRoot = CliE2ETestHelpers.GetRepoRoot();
var strategy = CliInstallStrategy.Detect(output.WriteLine);
var workspace = TemporaryWorkspace.Create(output);
using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, workspace: workspace);
var counter = new SequenceCounter();
var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken);
await auto.PrepareDockerEnvironmentAsync(counter, workspace);
await auto.InstallAspireCliAsync(strategy, counter);
await auto.RunCommandAsync("mkdir source-feed && cp \"$HOME\"/.aspire/hives/*/packages/Aspire.ProjectTemplates.*.nupkg source-feed/", counter);
await auto.RunCommandAsync("aspire --version > /tmp/aspire-source-version", counter);
// CLI update checks are independent from template source selection. Disable them so the
// TCP tripwire isolates template discovery and installation traffic from the update notifier.
await auto.RunCommandAsync("aspire config set features.updateNotificationsEnabled false -g", counter);
await auto.RunCommandAsync("aspire config set features.showAllTemplates true -g", counter);
await auto.RunCommandAsync(
"export ASPIRE_CLI_CHANNEL=staging ASPIRE_CLI_VERSION=\"$(cat /tmp/aspire-source-version)\" " +
"ASPIRE_CLI_COMMIT=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
counter);
// The selected .NET template restores after scaffolding. Keep that independent post-action
// on the local feed so the tripwire remains scoped to template discovery and installation.
await auto.RunCommandAsync("dotnet nuget disable source nuget.org", counter);
await auto.RunCommandAsync("dotnet nuget add source \"$PWD/source-feed\" --name source-override-e2e", counter);
// Package search can suppress source failures, so use a TCP tripwire to detect connection attempts independently of logs.
await auto.StartUnexpectedNuGetSourceContactTripwireAsync(counter, includeAzureDevOps: true);
await auto.RunCommandAsync("rm -rf \"$HOME/.aspire/logs\" && mkdir -p \"$HOME/.aspire/logs\"", counter);
await auto.RunCommandAsync(
"aspire new aspire-servicedefaults --name SourceOverrideServiceDefaults --output SourceOverrideServiceDefaults " +
"--channel staging --source source-feed --non-interactive --suppress-agent-init --log-level Debug",
counter,
TimeSpan.FromMinutes(2));
await auto.RunCommandAsync("test -f SourceOverrideServiceDefaults/SourceOverrideServiceDefaults.csproj", counter);
// A local `--source` directory is enumerated directly rather than queried through a feed, so
// the observable evidence is the install itself: it runs from a source-restricted child
// overlay that inherits ambient settings and points `dotnet new install` at the .nupkg
// copied into source-feed. Logged lines look like:
// Running dotnet in /workspace/aspire-nuget-configABC123 with args: new install /workspace/.../source-feed/Aspire.ProjectTemplates.13.0.0-preview.1.nupkg
// Match the argument list instead of the randomly-suffixed temp directory.
//
// The negative guard names the legacy bundled search path to ensure this local-source flow
// never regresses to spawning aspire-managed. That removed path logged:
// Running NuGet search via aspire-managed: Aspire.ProjectTemplates
// NuGet search args: nuget search --query Aspire.ProjectTemplates --take 1000 --format json ...
// Because the positive assertion above matches a Debug line from the same log, an empty or
// level-filtered log cannot make these negative assertions pass vacuously.
await auto.RunCommandAsync(
"find \"$HOME/.aspire/logs\" -type f -name '*.log' -print -quit | grep -q . && " +
"grep -R -F \"Resolved 'staging' channel\" \"$HOME/.aspire/logs\" && " +
"grep -R -E 'Running dotnet in .*aspire-nuget-config.* with args: new install [^ ]*/source-feed/Aspire\\.ProjectTemplates\\.[^ ]*\\.nupkg' \"$HOME/.aspire/logs\" && " +
"! grep -R -E -- 'Running NuGet search via aspire-managed: Aspire\\.ProjectTemplates|NuGet search args: nuget search --query Aspire\\.ProjectTemplates' \"$HOME/.aspire/logs\" && " +
"! grep -R -F -- '--nuget-source' \"$HOME/.aspire/logs\" && " +
"! grep -R -F 'api.nuget.org' \"$HOME/.aspire/logs\"",
counter);
await auto.AssertUnexpectedNuGetSourceWasNotContactedAsync(counter);
}
[CaptureWorkspaceOnFailure]
[Fact]
public async Task StableChannelUsesAmbientNuGetSourceForTemplateDiscoveryAndInstallation()
{
const string stableTemplateVersion = "99.99.99";
var repoRoot = CliE2ETestHelpers.GetRepoRoot();
var strategy = CliInstallStrategy.Detect(output.WriteLine);
Assert.SkipUnless(
strategy.Mode is CliInstallMode.LocalArchive or CliInstallMode.LocalHive,
$"The ambient template source test requires an archive containing Aspire.ProjectTemplates; current mode is {strategy.Mode}.");
var workspace = TemporaryWorkspace.Create(output);
using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, workspace: workspace);
var counter = new SequenceCounter();
var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken);
await auto.PrepareDockerEnvironmentAsync(counter, workspace);
await auto.InstallAspireCliAsync(strategy, counter);
await auto.RunCommandAsync(
"SOURCE_PACKAGE=$(find \"$HOME/.aspire/hives\" -path '*/packages/Aspire.ProjectTemplates.*.nupkg' -print -quit); " +
"test -n \"$SOURCE_PACKAGE\" && cp \"$SOURCE_PACKAGE\" source-template.nupkg",
counter);
var sourcePackage = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "source-template.nupkg"));
var ambientFeed = workspace.WorkspaceRoot.CreateSubdirectory("ambient-template-feed");
CreateTemplatePackageWithVersion(sourcePackage, ambientFeed, stableTemplateVersion);
sourcePackage.Delete();
WriteAmbientNuGetConfig(workspace.WorkspaceRoot, ambientFeed.Name);
// Remove the archive's package override so the explicitly requested stable channel has to
// discover Aspire.ProjectTemplates through the ambient NuGet hierarchy.
await auto.RunCommandAsync(
"unset ASPIRE_CLI_PACKAGES ASPIRE_CLI_VERSION ASPIRE_CLI_COMMIT; " +
"export NUGET_PACKAGES=\"$PWD/.nuget-packages\"",
counter);
await auto.RunCommandAsync("aspire config set features.updateNotificationsEnabled false -g", counter);
await auto.RunCommandAsync("aspire config set features.showAllTemplates true -g", counter);
await auto.StartUnexpectedNuGetSourceContactTripwireAsync(counter, includeAzureDevOps: false);
await auto.RunCommandAsync("rm -rf \"$HOME/.aspire/logs\" && mkdir -p \"$HOME/.aspire/logs\"", counter);
await auto.RunCommandAsync(
"aspire new aspire-servicedefaults --name AmbientServiceDefaults --output AmbientServiceDefaults " +
"--channel stable --non-interactive --suppress-agent-init --log-level Debug",
counter,
TimeSpan.FromMinutes(2));
await auto.RunCommandAsync("test -f AmbientServiceDefaults/AmbientServiceDefaults.csproj", counter);
await auto.RunCommandAsync(
$"find \"$HOME/.aspire/logs\" -type f -name '*.log' -print -quit | grep -q . && " +
$"grep -R -F \"Running dotnet in $PWD with args: new install $PWD/ambient-template-feed/Aspire.ProjectTemplates.{stableTemplateVersion}.nupkg\" \"$HOME/.aspire/logs\" && " +
"! grep -R -F 'aspire-nuget-config' \"$HOME/.aspire/logs\" && " +
"! grep -R -F 'api.nuget.org' \"$HOME/.aspire/logs\"",
counter);
await auto.AssertUnexpectedNuGetSourceWasNotContactedAsync(counter);
}
[CaptureWorkspaceOnFailure]
[Fact]
public async Task DailyChannelOverlayDoesNotReintroduceNuGetOrgForTemplateDiscoveryAndInstallation()
{
var repoRoot = CliE2ETestHelpers.GetRepoRoot();
var strategy = CliInstallStrategy.Detect(output.WriteLine);
var workspace = TemporaryWorkspace.Create(output);
var ambientFeed = workspace.WorkspaceRoot.CreateSubdirectory("ambient-empty-feed");
WriteAmbientNuGetConfig(workspace.WorkspaceRoot, ambientFeed.Name);
using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, workspace: workspace);
var counter = new SequenceCounter();
var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken);
await auto.PrepareDockerEnvironmentAsync(counter, workspace);
await auto.InstallAspireCliAsync(strategy, counter);
// Ensure the built-in daily channel, rather than an archive-backed identity override, owns
// template resolution. The empty ambient feed remains available for unrelated packages.
await auto.RunCommandAsync(
"unset ASPIRE_CLI_PACKAGES ASPIRE_CLI_VERSION ASPIRE_CLI_COMMIT; " +
"export NUGET_PACKAGES=\"$PWD/.nuget-packages\"",
counter);
await auto.RunCommandAsync("aspire config set features.updateNotificationsEnabled false -g", counter);
await auto.RunCommandAsync("aspire config set features.showAllTemplates true -g", counter);
await auto.StartUnexpectedNuGetSourceContactTripwireAsync(counter, includeAzureDevOps: false);
await auto.RunCommandAsync("rm -rf \"$HOME/.aspire/logs\" && mkdir -p \"$HOME/.aspire/logs\"", counter);
await auto.RunCommandAsync(
"aspire new aspire-servicedefaults --name DailyServiceDefaults --output DailyServiceDefaults " +
"--channel daily --non-interactive --suppress-agent-init --log-level Debug",
counter,
TimeSpan.FromMinutes(3));
await auto.RunCommandAsync("test -f DailyServiceDefaults/DailyServiceDefaults.csproj", counter);
await auto.RunCommandAsync(
"find \"$HOME/.aspire/logs\" -type f -name '*.log' -print -quit | grep -q . && " +
"grep -R -E \"Running dotnet in $PWD/\\.aspire-nuget-config[^ ]* with args: new install Aspire\\.ProjectTemplates@[^ ]+\" \"$HOME/.aspire/logs\" && " +
"! grep -R -F 'api.nuget.org' \"$HOME/.aspire/logs\"",
counter);
await auto.AssertUnexpectedNuGetSourceWasNotContactedAsync(counter);
}
private static void CreateTemplatePackageWithVersion(
FileInfo sourcePackage,
DirectoryInfo destinationFeed,
string packageVersion)
{
var destinationPath = Path.Combine(destinationFeed.FullName, $"Aspire.ProjectTemplates.{packageVersion}.nupkg");
using var sourceArchive = ZipFile.OpenRead(sourcePackage.FullName);
using var destinationArchive = ZipFile.Open(destinationPath, ZipArchiveMode.Create);
var foundNuspec = false;
foreach (var sourceEntry in sourceArchive.Entries)
{
// Package signatures cover the complete archive and cannot survive the deliberate nuspec
// version rewrite. Local PR archives are normally unsigned, but omit the signature if one
// is present so the rewritten fixture remains a valid unsigned package.
if (string.Equals(sourceEntry.FullName, ".signature.p7s", StringComparison.OrdinalIgnoreCase))
{
continue;
}
var destinationEntry = destinationArchive.CreateEntry(sourceEntry.FullName, CompressionLevel.Optimal);
destinationEntry.LastWriteTime = sourceEntry.LastWriteTime;
if (sourceEntry.FullName.EndsWith(".nuspec", StringComparison.OrdinalIgnoreCase))
{
foundNuspec = true;
using var sourceStream = sourceEntry.Open();
var document = XDocument.Load(sourceStream, LoadOptions.PreserveWhitespace);
var versionElement = document
.Descendants()
.Single(element => string.Equals(element.Name.LocalName, "version", StringComparison.Ordinal));
versionElement.Value = packageVersion;
using var destinationStream = destinationEntry.Open();
document.Save(destinationStream, SaveOptions.DisableFormatting);
}
else
{
using var sourceStream = sourceEntry.Open();
using var destinationStream = destinationEntry.Open();
sourceStream.CopyTo(destinationStream);
}
}
Assert.True(foundNuspec, $"Expected a nuspec in template package '{sourcePackage.FullName}'.");
}
private static void WriteAmbientNuGetConfig(DirectoryInfo workspace, string sourcePath)
{
File.WriteAllText(
Path.Combine(workspace.FullName, "NuGet.Config"),
$"""
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<clear />
<add key="ambient-internal" value="{sourcePath}" />
</packageSources>
</configuration>
""");
}
}