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 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 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);
 
        // 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)
        // Stable channel does not prompt for NuGet.config creation because it maps
        // Aspire* to nuget.org and there is no existing config.
        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);
    }
 
    [Fact]
    public async Task AspireUpdateRemovesOrphanAppHostPackageVersionWhenSdkAlreadyCurrent()
    {
        // Reproduces https://github.com/microsoft/aspire/issues/15476.
        //
        // Scenario: an AppHost is already on the latest stable Aspire SDK using the
        // new <Project Sdk="Aspire.AppHost.Sdk/<version>"> format, and the user has
        // (or copied) a CPM PackageVersion entry for Aspire.Hosting.AppHost in
        // Directory.Packages.props. Because the new SDK implicitly adds the
        // Aspire.Hosting.AppHost PackageReference with IsImplicitlyDefined=true,
        // NuGet rejects the orphan PackageVersion with NU1009.
        //
        // PR #14585 fixed the migration path (old format -> new format) by removing
        // the orphan PackageVersion as part of the SDK update step. But that step is
        // skipped entirely in AnalyzeAppHostSdkAsync when the SDK version is already
        // current, so a second run of `aspire update` does not clean up an
        // orphan that was introduced after the initial migration.
        //
        // To get the project onto the *exact* latest stable SDK without hard-coding
        // the version (which would go stale), this test runs `aspire update` once to
        // migrate from an old SDK version. After that first update the csproj is on
        // the latest stable SDK and Directory.Packages.props is clean. The test then
        // re-introduces an orphan PackageVersion entry and runs `aspire update`
        // again - which currently leaves the orphan in place and breaks the project.
 
        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);
 
        // 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);
 
        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);
 
        // Start from an old-format AppHost so the first `aspire update` performs an
        // SDK migration and pins the csproj to the latest stable SDK version.
        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>
            </Project>
            """);
 
        File.WriteAllText(Path.Combine(appHostDir, "Program.cs"), """
            var builder = DistributedApplication.CreateBuilder(args);
            builder.Build().Run();
            """);
 
        // Start with a CPM-enabled props file that has no orphans yet.
        File.WriteAllText(directoryPackagesPropsPath, """
            <Project>
                <PropertyGroup>
                    <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
                </PropertyGroup>
            </Project>
            """);
 
        // First update: migrate to the new SDK format on the latest stable version.
        // Stable channel does not prompt for NuGet.config creation because it maps
        // Aspire* to nuget.org and there is no existing config.
        await auto.TypeAsync($"aspire update --project \"{containerAppHostCsprojPath}\" --channel stable");
        await auto.EnterAsync();
        await auto.WaitUntilTextAsync("Perform updates?", timeout: TimeSpan.FromSeconds(60));
        await auto.EnterAsync();
        await auto.WaitUntilTextAsync("Update successful!", timeout: TimeSpan.FromSeconds(60));
        await auto.WaitForSuccessPromptAsync(counter);
 
        // Now the csproj is on the latest stable SDK. Discover that version from the
        // migrated csproj so we can write a matching orphan PackageVersion entry.
        var migratedCsproj = File.ReadAllText(appHostCsprojPath);
        var sdkMatch = System.Text.RegularExpressions.Regex.Match(
            migratedCsproj,
            @"Aspire\.AppHost\.Sdk/(?<version>[^""\s;]+)");
        if (!sdkMatch.Success)
        {
            throw new InvalidOperationException(
                $"Could not find Aspire.AppHost.Sdk/<version> directive in migrated csproj:\n{migratedCsproj}");
        }
        var latestStableSdkVersion = sdkMatch.Groups["version"].Value;
        output.WriteLine($"Latest stable AppHost SDK version: {latestStableSdkVersion}");
 
        // Re-introduce an orphan PackageVersion for Aspire.Hosting.AppHost. This is
        // the configuration that triggers NU1009 because the new SDK implicitly adds
        // the matching PackageReference.
        File.WriteAllText(directoryPackagesPropsPath, $$"""
            <Project>
                <PropertyGroup>
                    <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
                </PropertyGroup>
                <ItemGroup>
                    <PackageVersion Include="Aspire.Hosting.AppHost" Version="{{latestStableSdkVersion}}" />
                </ItemGroup>
            </Project>
            """);
 
        // Second update: SDK is already current, so AnalyzeAppHostSdkAsync will
        // skip the SDK update step. The updater must still detect and remove the
        // orphan PackageVersion - that cleanup is itself an update step, so the
        // run prompts for confirmation just like the first update did.
        // Stable channel does not prompt for NuGet.config because it maps Aspire*
        // to nuget.org and there is no existing config.
        await auto.TypeAsync($"aspire update --project \"{containerAppHostCsprojPath}\" --channel stable");
        await auto.EnterAsync();
        await auto.WaitUntilTextAsync("Perform updates?", timeout: TimeSpan.FromSeconds(60));
        await auto.EnterAsync();
        await auto.WaitUntilTextAsync("Update successful!", timeout: TimeSpan.FromSeconds(60));
        await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(120));
 
        // Verify the orphan PackageVersion was removed from Directory.Packages.props.
        {
            var content = File.ReadAllText(directoryPackagesPropsPath);
            if (content.Contains("Aspire.Hosting.AppHost"))
            {
                throw new InvalidOperationException(
                    $"File {directoryPackagesPropsPath} unexpectedly still contains an Aspire.Hosting.AppHost entry after the second `aspire update`:\n{content}");
            }
        }
 
        // Verify dotnet restore succeeds. Without the fix this fails with NU1009.
        await auto.TypeAsync($"dotnet restore \"{containerAppHostCsprojPath}\"");
        await auto.EnterAsync();
        await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(120));
 
        await auto.TypeAsync("aspire config delete features.updateNotificationsEnabled -g");
        await auto.EnterAsync();
        await auto.WaitForSuccessPromptAsync(counter);
    }
 
    [Fact]
    public async Task AspireAddPackageVersionToDirectoryPackagesProps()
    {
        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);
 
        // 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>
            """);
 
        await auto.TypeAsync("aspire add Aspire.Hosting.Redis --non-interactive");
        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 directoryPackagesProps = XDocument.Load(directoryPackagesPropsPath);
 
            static IEnumerable<XElement> FindRedisProperties(XDocument document, string propertyName)
            {
                return document.Descendants()
                    .Where(element => element.Name.LocalName == propertyName)
                    .Where(element => string.Equals((string?)element.Attribute("Include"), "Aspire.Hosting.Redis", StringComparison.Ordinal));
            }
 
            var projectHasRedisVersionPin = FindRedisProperties(appHostProject, "PackageReference")
                .Any(element => element.Attribute("Version") is not null);
            var directoryPackagesHasRedisVersionPin = FindRedisProperties(directoryPackagesProps, "PackageVersion")
                .Single().Attribute("Version") is not null;
 
            if (projectHasRedisVersionPin)
            {
                throw new InvalidOperationException($"File {appHostCsprojPath} unexpectedly contains a version-pinned PackageReference for Aspire.Hosting.Redis");
            }
            if (!directoryPackagesHasRedisVersionPin)
            {
                throw new InvalidOperationException($"File {directoryPackagesPropsPath} unexpectedly does not contain the central PackageVersion for Aspire.Hosting.Redis");
            }
        }
 
        // Verify dotnet restore succeeds (would fail with NU1008 if AppHost.csproj contained a version)
        await auto.TypeAsync($"dotnet restore \"{containerAppHostCsprojPath}\"");
        await auto.EnterAsync();
        await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(120));
    }
}