File: CentralPackageManagementTests.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.Xml.Linq;
 
using Aspire.Cli.EndToEnd.Tests.Helpers;
using Aspire.Cli.Tests.Utils;
using Hex1b.Automation;
using Xunit;
 
namespace Aspire.Cli.EndToEnd.Tests;
 
/// <summary>
/// End-to-end tests for Central Package Management (CPM) compatibility.
/// Validates that aspire update correctly handles CPM projects.
/// Each test class runs as a separate CI job for parallelization.
/// </summary>
public sealed class CentralPackageManagementTests(ITestOutputHelper output)
{
    [Fact]
    public async Task AspireUpdateRemovesAppHostPackageVersionFromDirectoryPackagesProps()
    {
        var repoRoot = CliE2ETestHelpers.GetRepoRoot();
        var installMode = CliE2ETestHelpers.DetectDockerInstallMode(repoRoot);
        var workspace = TemporaryWorkspace.Create(output);
 
        using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, installMode, output, workspace: workspace);
 
        var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken);
 
        var counter = new SequenceCounter();
        var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
 
        await auto.PrepareDockerEnvironmentAsync(counter, workspace);
 
        await auto.InstallAspireCliInDockerAsync(installMode, counter);
 
        // Disable update notifications to prevent the CLI self-update prompt
        // from appearing after "Update successful!" and blocking the test.
        await auto.TypeAsync("aspire config set features.updateNotificationsEnabled false -g");
        await auto.EnterAsync();
        await auto.WaitForSuccessPromptAsync(counter);
 
        // Set up an old-format AppHost project with CPM that has a PackageVersion
        // for Aspire.Hosting.AppHost. This simulates a pre-migration project where
        // the user adopted CPM before the SDK started adding the implicit reference.
        var projectDir = Path.Combine(workspace.WorkspaceRoot.FullName, "CpmTest");
        var appHostDir = Path.Combine(projectDir, "CpmTest.AppHost");
        var appHostCsprojPath = Path.Combine(appHostDir, "CpmTest.AppHost.csproj");
        var directoryPackagesPropsPath = Path.Combine(projectDir, "Directory.Packages.props");
        var containerAppHostCsprojPath = CliE2ETestHelpers.ToContainerPath(appHostCsprojPath, workspace);
 
        Directory.CreateDirectory(appHostDir);
 
        File.WriteAllText(appHostCsprojPath, """
            <Project Sdk="Microsoft.NET.Sdk">
                <Sdk Name="Aspire.AppHost.Sdk" Version="9.1.0" />
                <PropertyGroup>
                    <OutputType>Exe</OutputType>
                    <TargetFramework>net9.0</TargetFramework>
                    <IsAspireHost>true</IsAspireHost>
                </PropertyGroup>
                <ItemGroup>
                    <PackageReference Include="Aspire.Hosting.AppHost" />
                </ItemGroup>
            </Project>
            """);
 
        File.WriteAllText(Path.Combine(appHostDir, "Program.cs"), """
            var builder = DistributedApplication.CreateBuilder(args);
            builder.Build().Run();
            """);
 
        File.WriteAllText(directoryPackagesPropsPath, """
            <Project>
                <PropertyGroup>
                    <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
                </PropertyGroup>
                <ItemGroup>
                    <PackageVersion Include="Aspire.Hosting.AppHost" Version="9.1.0" />
                </ItemGroup>
            </Project>
            """);
 
        // Use --channel stable to skip the channel selection prompt that appears
        // in CI when PR hive directories are present.
        await auto.TypeAsync($"aspire update --project \"{containerAppHostCsprojPath}\" --channel stable");
        await auto.EnterAsync();
        await auto.WaitUntilTextAsync("Perform updates?", timeout: TimeSpan.FromSeconds(60));
        await auto.EnterAsync(); // confirm "Perform updates?" (default: Yes)
        // The updater may prompt for a NuGet.config location and ask to apply changes
        // when the project doesn't have an existing NuGet.config. Accept defaults for both.
        await auto.WaitUntilTextAsync("Which directory for NuGet.config file?", timeout: TimeSpan.FromSeconds(30));
        await auto.EnterAsync(); // accept default directory
        await auto.WaitUntilTextAsync("Apply these changes to NuGet.config?", timeout: TimeSpan.FromSeconds(30));
        await auto.EnterAsync(); // confirm "Apply these changes to NuGet.config?" (default: Yes)
        await auto.WaitUntilTextAsync("Update successful!", timeout: TimeSpan.FromSeconds(60));
        await auto.WaitForSuccessPromptAsync(counter);
 
        // Verify the PackageVersion for Aspire.Hosting.AppHost was removed
        {
            var content = File.ReadAllText(directoryPackagesPropsPath);
            if (content.Contains("Aspire.Hosting.AppHost"))
            {
                throw new InvalidOperationException($"File {directoryPackagesPropsPath} unexpectedly contains: Aspire.Hosting.AppHost");
            }
        }
 
        // Verify dotnet restore succeeds (would fail with NU1009 without the fix)
        await auto.TypeAsync($"dotnet restore \"{containerAppHostCsprojPath}\"");
        await auto.EnterAsync();
        await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(120));
        // Clean up: re-enable update notifications
        await auto.TypeAsync("aspire config delete features.updateNotificationsEnabled -g");
        await auto.EnterAsync();
        await auto.WaitForSuccessPromptAsync(counter);
        await auto.TypeAsync("exit");
        await auto.EnterAsync();
 
        await pendingRun;
    }
 
    [Fact]
    public async Task AspireAddPackageVersionToDirectoryPackagesProps()
    {
        var repoRoot = CliE2ETestHelpers.GetRepoRoot();
        var installMode = CliE2ETestHelpers.DetectDockerInstallMode(repoRoot);
        var workspace = TemporaryWorkspace.Create(output);
 
        using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, installMode, output, workspace: workspace);
 
        var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken);
 
        var counter = new SequenceCounter();
        var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
 
        await auto.PrepareDockerEnvironmentAsync(counter, workspace);
 
        await auto.InstallAspireCliInDockerAsync(installMode, counter);
 
        // Set up an AppHost project with CPM, but no installed packages
        var projectDir = Path.Combine(workspace.WorkspaceRoot.FullName, "CpmTest");
        var appHostDir = Path.Combine(projectDir, "CpmTest.AppHost");
        var appHostCsprojPath = Path.Combine(appHostDir, "CpmTest.AppHost.csproj");
        var directoryPackagesPropsPath = Path.Combine(projectDir, "Directory.Packages.props");
        var containerAppHostCsprojPath = CliE2ETestHelpers.ToContainerPath(appHostCsprojPath, workspace);
 
        Directory.CreateDirectory(appHostDir);
 
        File.WriteAllText(appHostCsprojPath, """
            <Project Sdk="Aspire.AppHost.Sdk/13.1.2">
                <PropertyGroup>
                    <OutputType>Exe</OutputType>
                    <TargetFramework>net9.0</TargetFramework>
                    <IsAspireHost>true</IsAspireHost>
                </PropertyGroup>
            </Project>
            """);
 
        File.WriteAllText(Path.Combine(appHostDir, "Program.cs"), """
            var builder = DistributedApplication.CreateBuilder(args);
            builder.Build().Run();
            """);
 
        File.WriteAllText(directoryPackagesPropsPath, """
            <Project>
                <PropertyGroup>
                    <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
                </PropertyGroup>
            </Project>
            """);
 
        var waitingForVersionSelection = new CellPatternSearcher()
            .Find("Select a version of");
        var versionSelectionShown = false;
 
        await auto.TypeAsync("aspire add Aspire.Hosting.Redis");
        await auto.EnterAsync();
        await auto.WaitUntilAsync(s =>
        {
            if (waitingForVersionSelection.Search(s).Count > 0)
            {
                versionSelectionShown = true;
                return true;
            }
 
            var successPromptSearcher = new CellPatternSearcher()
                .FindPattern(counter.Value.ToString())
                .RightText(" OK] $ ");
 
            return successPromptSearcher.Search(s).Count > 0;
        }, timeout: TimeSpan.FromSeconds(180), description: "version selection prompt or success prompt");
 
        if (versionSelectionShown)
        {
            // PR hives can surface multiple channels in CI. Accept the default implicit-channel version
            // so this test validates CPM behavior without pinning a specific package version.
            await auto.EnterAsync();
        }
 
        await auto.WaitForSuccessPromptAsync(counter);
 
        // Verify the AppHost project does not end up with a version-pinned Redis PackageReference.
        {
            var appHostProject = XDocument.Load(appHostCsprojPath);
            var hasVersionPinnedRedisReference = false;
 
            foreach (var element in appHostProject.Descendants())
            {
                if (element.Name.LocalName == "PackageReference" &&
                    string.Equals((string?)element.Attribute("Include"), "Aspire.Hosting.Redis", StringComparison.Ordinal) &&
                    element.Attribute("Version") is not null)
                {
                    hasVersionPinnedRedisReference = true;
                    break;
                }
            }
 
            if (hasVersionPinnedRedisReference)
            {
                throw new InvalidOperationException($"File {appHostCsprojPath} unexpectedly contains a version-pinned PackageReference for Aspire.Hosting.Redis");
            }
        }
 
        // Verify dotnet restore succeeds (would fail with NU1009 if AppHost.csproj contained a version)
        await auto.TypeAsync($"dotnet restore \"{containerAppHostCsprojPath}\"");
        await auto.EnterAsync();
        await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(120));
        await auto.TypeAsync("exit");
        await auto.EnterAsync();
 
        await pendingRun;
    }
}