File: EmptyAppHostTemplateTests.cs
Web Access
Project: src\tests\Aspire.Cli.EndToEnd.Tests\Aspire.Cli.EndToEnd.Tests.csproj (Aspire.Cli.EndToEnd.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.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>
            """);
    }
}