File: Projects\ProjectUpdaterTests.cs
Web Access
Project: src\tests\Aspire.Cli.Tests\Aspire.Cli.Tests.csproj (Aspire.Cli.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.Text.Json;
using System.Text.Json.Nodes;
using Aspire.Cli.DotNet;
using Aspire.Cli.Interaction;
using Aspire.Cli.Packaging;
using Aspire.Cli.Projects;
using Aspire.Cli.Tests.TestServices;
using Aspire.Cli.Tests.Utils;
using Aspire.Shared;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Testing;
using Microsoft.AspNetCore.InternalTesting;
 
namespace Aspire.Cli.Tests.Projects;
 
public class ProjectUpdaterTests(ITestOutputHelper outputHelper)
{
    [Fact]
    public async Task UpdateProjectFileAsync_DoesAttemptToUpdateIfNoUpdatesRequired()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var srcFolder = workspace.CreateDirectory("src");
 
        var serviceDefaultsFolder = workspace.CreateDirectory("UpdateTester.ServiceDefaults");
        var serviceDefaultsProjectFile = new FileInfo(Path.Combine(serviceDefaultsFolder.FullName, "UpdateTester.ServiceDefaults.csproj"));
 
        var webAppFolder = workspace.CreateDirectory("UpdateTester.WebApp");
        var webAppProjectFile = new FileInfo(Path.Combine(webAppFolder.FullName, "UpdateTester.WebApp.csproj"));
 
        var appHostFolder = workspace.CreateDirectory("UpdateTester.AppHost");
        var appHostProjectFile = new FileInfo(Path.Combine(appHostFolder.FullName, "UpdateTester.AppHost.csproj"));
 
        await File.WriteAllTextAsync(
            appHostProjectFile.FullName,
            $$"""
            <Project Sdk="Microsoft.NET.Sdk">
                <Sdk Name="Aspire.AppHost.Sdk" Version="9.5.1-preview.1" />
            </Project>
            """);
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, config =>
        {
            config.DotNetCliRunnerFactory = (sp) =>
            {
                return new TestDotNetCliRunner()
                {
                    SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _, _) =>
                    {
                        var packages = new List<NuGetPackageCli>();
 
                        packages.Add(query switch
                        {
                            "Aspire.AppHost.Sdk" => new NuGetPackageCli { Id = "Aspire.AppHost.Sdk", Version = "9.4.1", Source = "nuget.org" },
                            "Aspire.Hosting.AppHost" => new NuGetPackageCli { Id = "Aspire.Hosting.AppHost", Version = "9.4.1", Source = "nuget.org" },
                            "Aspire.Hosting.Redis" => new NuGetPackageCli { Id = "Aspire.Hosting.Redis", Version = "9.4.1", Source = "nuget.org" },
                            "Aspire.StackExchange.Redis.OutputCaching" => new NuGetPackageCli { Id = "Aspire.StackExchange.Redis.OutputCaching", Version = "9.4.1", Source = "nuget.org" },
                            "Microsoft.Extensions.ServiceDiscovery" => new NuGetPackageCli { Id = "Microsoft.Extensions.ServiceDiscovery", Version = "9.4.1", Source = "nuget.org" },
                            _ => throw new InvalidOperationException("Unexpected package query."),
                        });
 
                        return (0, packages.ToArray());
                    },
 
                    GetProjectItemsAndPropertiesAsyncCallback = (projectFile, _, _, _, _) =>
                    {
                        var itemsAndProperties = new JsonObject();
 
                        if (projectFile.FullName == appHostProjectFile.FullName)
                        {
                            itemsAndProperties.WithSdkVersion("9.4.1");
                            itemsAndProperties.WithPackageReference("Aspire.Hosting.AppHost", "9.4.1");
                            itemsAndProperties.WithPackageReference("Aspire.Hosting.Redis", "9.4.1");
                            itemsAndProperties.WithProjectReference(webAppProjectFile.FullName);
                        }
                        else if (projectFile.FullName == webAppProjectFile.FullName)
                        {
                            itemsAndProperties.WithPackageReference("Aspire.StackExchange.Redis.OutputCaching", "9.4.1");
                            itemsAndProperties.WithProjectReference(serviceDefaultsProjectFile.FullName);
                        }
                        else if (projectFile.FullName == serviceDefaultsProjectFile.FullName)
                        {
                            itemsAndProperties.WithPackageReference("Microsoft.ServiceDiscovery.Extensions", "9.4.1");
                        }
                        else
                        {
                            throw new InvalidOperationException("Unexpected project file.");
                        }
 
                        var json = itemsAndProperties.ToJsonString();
                        var document = JsonDocument.Parse(json);
                        return (0, document);
                    }
                };
            };
 
            config.InteractionServiceFactory = (sp) =>
            {
                var interactionService = new TestInteractionService();
                interactionService.ConfirmCallback = (promptText, defaultValue) =>
                {
                    throw new InvalidOperationException("Should not prompt when no work required.");
                };
 
                return interactionService;
            };
        });
        using var provider = services.BuildServiceProvider();
 
        // Services we need for project updater.
        var logger = provider.GetRequiredService<ILogger<ProjectUpdater>>();
        var runner = provider.GetRequiredService<IDotNetCliRunner>();
        var interactionService = provider.GetRequiredService<IInteractionService>();
        var cache = provider.GetRequiredService<IMemoryCache>();
        var executionContext = CreateExecutionContext(workspace.WorkspaceRoot);
        var packagingService = provider.GetRequiredService<IPackagingService>();
 
        var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
        var selectedChannel = channels.Single(c => c.Name == "default");
 
        // If this throws then it means that the updater prompted
        // for confirmation to do an update when no update was required!
        var projectUpdater = provider.GetRequiredService<IProjectUpdater>();
        var updateResult = await projectUpdater.UpdateProjectAsync(CreateUpdateContext(appHostProjectFile, selectedChannel)).DefaultTimeout();
        Assert.False(updateResult.UpdatedApplied);
    }
 
    [Fact]
    public async Task UpdateProjectFileAsync_CanUpdateFromStableToDaily()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var srcFolder = workspace.CreateDirectory("src");
 
        var serviceDefaultsFolder = workspace.CreateDirectory("UpdateTester.ServiceDefaults");
        var serviceDefaultsProjectFile = new FileInfo(Path.Combine(serviceDefaultsFolder.FullName, "UpdateTester.ServiceDefaults.csproj"));
 
        var webAppFolder = workspace.CreateDirectory("UpdateTester.WebApp");
        var webAppProjectFile = new FileInfo(Path.Combine(webAppFolder.FullName, "UpdateTester.WebApp.csproj"));
 
        var appHostFolder = workspace.CreateDirectory("UpdateTester.AppHost");
        var appHostProjectFile = new FileInfo(Path.Combine(appHostFolder.FullName, "UpdateTester.AppHost.csproj"));
 
        await File.WriteAllTextAsync(
            appHostProjectFile.FullName,
            $$"""
            <Project Sdk="Microsoft.NET.Sdk">
                <Sdk Name="Aspire.AppHost.Sdk" Version="9.4.1" />
            </Project>
            """);
 
        // Pre-existing aspire.config.json pinned to a (stale) channel — the updater should
        // rewrite the channel to match the resolved explicit channel after the update completes.
        // See https://github.com/microsoft/aspire/issues/17295.
        var aspireConfigFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "aspire.config.json"));
        await File.WriteAllTextAsync(
            aspireConfigFile.FullName,
            """
            {
              "appHost": { "path": "UpdateTester.AppHost/UpdateTester.AppHost.csproj" },
              "channel": "stable",
              "sdk": { "version": "9.4.1" }
            }
            """);
 
        var packagesAddsExecuted = new List<(FileInfo ProjectFile, string PackageId, string PackageVersion, string? PackageSource, bool NoRestore)>();
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, config =>
        {
            config.DotNetCliRunnerFactory = (sp) =>
            {
                return new TestDotNetCliRunner()
                {
                    SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _, _) =>
                    {
                        var packages = new List<NuGetPackageCli>();
 
                        packages.Add(query switch
                        {
                            "Aspire.AppHost.Sdk" => new NuGetPackageCli { Id = "Aspire.AppHost.Sdk", Version = "9.5.0-preview.1", Source = "daily" },
                            "Aspire.Hosting.AppHost" => new NuGetPackageCli { Id = "Aspire.Hosting.AppHost", Version = "9.5.0-preview.1", Source = "daily" },
                            "Aspire.Hosting.Redis" => new NuGetPackageCli { Id = "Aspire.Hosting.Redis", Version = "9.5.0-preview.1", Source = "daily" },
                            "Aspire.StackExchange.Redis.OutputCaching" => new NuGetPackageCli { Id = "Aspire.StackExchange.Redis.OutputCaching", Version = "9.5.0-preview.1", Source = "daily" },
                            "Microsoft.Extensions.ServiceDiscovery" => new NuGetPackageCli { Id = "Microsoft.Extensions.ServiceDiscovery", Version = "9.5.0-preview.1", Source = "daily" },
                            _ => throw new InvalidOperationException("Unexpected package query."),
                        });
 
                        return (0, packages.ToArray());
                    },
 
                    GetProjectItemsAndPropertiesAsyncCallback = (projectFile, _, _, _, _) =>
                    {
                        var itemsAndProperties = new JsonObject();
 
                        if (projectFile.FullName == appHostProjectFile.FullName)
                        {
                            itemsAndProperties.WithSdkVersion("9.4.1");
                            itemsAndProperties.WithPackageReference("Aspire.Hosting.AppHost", "9.4.1");
                            itemsAndProperties.WithPackageReference("Aspire.Hosting.Redis", "9.4.1");
                            itemsAndProperties.WithProjectReference(webAppProjectFile.FullName);
                        }
                        else if (projectFile.FullName == webAppProjectFile.FullName)
                        {
                            itemsAndProperties.WithPackageReference("Aspire.StackExchange.Redis.OutputCaching", "9.4.1");
                            itemsAndProperties.WithProjectReference(serviceDefaultsProjectFile.FullName);
                        }
                        else if (projectFile.FullName == serviceDefaultsProjectFile.FullName)
                        {
                            itemsAndProperties.WithPackageReference("Microsoft.Extensions.ServiceDiscovery", "9.4.1");
                        }
                        else
                        {
                            throw new InvalidOperationException("Unexpected project file.");
                        }
 
                        var json = itemsAndProperties.ToJsonString();
                        var document = JsonDocument.Parse(json);
                        return (0, document);
                    },
                    // FileInfo, string, string, string?, ProcessInvocationOptions, CancellationToken, int
                    AddPackageAsyncCallback = (projectFile, packageId, packageVersion, source, noRestore, _, _) =>
                    {
                        packagesAddsExecuted.Add((projectFile, packageId, packageVersion, source!, noRestore));
                        return 0;
                    }
                };
            };
 
            config.InteractionServiceFactory = (s) =>
            {
                var interactionService = new TestInteractionService();
                return interactionService;
            };
        });
        using var provider = services.BuildServiceProvider();
 
        // Services we need for project updater.
        var logger = provider.GetRequiredService<ILogger<ProjectUpdater>>();
        var runner = provider.GetRequiredService<IDotNetCliRunner>();
        var interactionService = provider.GetRequiredService<IInteractionService>();
        var cache = provider.GetRequiredService<IMemoryCache>();
        var executionContext = CreateExecutionContext(workspace.WorkspaceRoot);
        var packagingService = provider.GetRequiredService<IPackagingService>();
 
        var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
        var selectedChannel = channels.Single(c => c.Name == "daily");
 
        // If this throws then it means that the updater prompted
        // for confirmation to do an update when no update was required!
        var projectUpdater = provider.GetRequiredService<IProjectUpdater>();
        var updateResult = await projectUpdater.UpdateProjectAsync(CreateUpdateContext(appHostProjectFile, selectedChannel)).DefaultTimeout();
 
        Assert.True(updateResult.UpdatedApplied);
        // Note: Aspire.Hosting.AppHost is not updated because it's removed during SDK migration
        Assert.Collection(
            packagesAddsExecuted,
            item =>
            {
                Assert.Equal("Aspire.Hosting.Redis", item.PackageId);
                Assert.Equal("9.5.0-preview.1", item.PackageVersion);
                // PackageSource is null because ProjectUpdater passes nugetSource: null to AddPackageAsync.
                Assert.Null(item.PackageSource);
                // Per-package 'dotnet package add' must use --no-restore so the deferred final restore
                // sees a fully-updated reference graph (regression guard for https://github.com/dotnet/aspire/issues/15891).
                Assert.True(item.NoRestore);
                Assert.Equal(appHostProjectFile.FullName, item.ProjectFile.FullName);
            },
            item =>
            {
                Assert.Equal("Aspire.StackExchange.Redis.OutputCaching", item.PackageId);
                Assert.Equal("9.5.0-preview.1", item.PackageVersion);
                // PackageSource is null because ProjectUpdater passes nugetSource: null to AddPackageAsync.
                Assert.Null(item.PackageSource);
                // Per-package 'dotnet package add' must use --no-restore so the deferred final restore
                // sees a fully-updated reference graph (regression guard for https://github.com/dotnet/aspire/issues/15891).
                Assert.True(item.NoRestore);
                Assert.Equal(webAppProjectFile.FullName, item.ProjectFile.FullName);
            }
        );
 
        // Channel pin in aspire.config.json should have been rewritten from the pre-existing
        // "stable" value to the resolved explicit channel ("daily") that we just updated to.
        // Regression guard for https://github.com/microsoft/aspire/issues/17295.
        var updatedConfig = await File.ReadAllTextAsync(aspireConfigFile.FullName);
        using var configDoc = JsonDocument.Parse(updatedConfig);
        Assert.Equal("daily", configDoc.RootElement.GetProperty("channel").GetString());
        Assert.Equal("9.5.0-preview.1", configDoc.RootElement.GetProperty("sdk").GetProperty("version").GetString());
    }
 
    [Fact]
    public async Task UpdateProjectFileAsync_DoesNotCreateAspireConfigJsonWhenAbsent()
    {
        // Sibling of UpdateProjectFileAsync_CanUpdateFromStableToDaily covering the case where
        // the AppHost project predates aspire.config.json (legacy split layouts, pre-init projects).
        // `aspire update` must not fabricate a fresh aspire.config.json — that's `aspire init`'s job.
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var srcFolder = workspace.CreateDirectory("src");
 
        var serviceDefaultsFolder = workspace.CreateDirectory("UpdateTester.ServiceDefaults");
        var serviceDefaultsProjectFile = new FileInfo(Path.Combine(serviceDefaultsFolder.FullName, "UpdateTester.ServiceDefaults.csproj"));
 
        var webAppFolder = workspace.CreateDirectory("UpdateTester.WebApp");
        var webAppProjectFile = new FileInfo(Path.Combine(webAppFolder.FullName, "UpdateTester.WebApp.csproj"));
 
        var appHostFolder = workspace.CreateDirectory("UpdateTester.AppHost");
        var appHostProjectFile = new FileInfo(Path.Combine(appHostFolder.FullName, "UpdateTester.AppHost.csproj"));
 
        await File.WriteAllTextAsync(
            appHostProjectFile.FullName,
            $$"""
            <Project Sdk="Microsoft.NET.Sdk">
                <Sdk Name="Aspire.AppHost.Sdk" Version="9.4.1" />
            </Project>
            """);
 
        // Intentionally do NOT pre-create aspire.config.json. The post-update assertion below
        // verifies the file is still absent.
        var aspireConfigFile = new FileInfo(Path.Combine(appHostFolder.FullName, "aspire.config.json"));
        Assert.False(aspireConfigFile.Exists, "Pre-condition: aspire.config.json should not exist before the update.");
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, config =>
        {
            config.DotNetCliRunnerFactory = (sp) =>
            {
                return new TestDotNetCliRunner()
                {
                    SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _, _) =>
                    {
                        var packages = new List<NuGetPackageCli>
                        {
                            query switch
                            {
                                "Aspire.AppHost.Sdk" => new NuGetPackageCli { Id = "Aspire.AppHost.Sdk", Version = "9.5.0-preview.1", Source = "daily" },
                                "Aspire.Hosting.AppHost" => new NuGetPackageCli { Id = "Aspire.Hosting.AppHost", Version = "9.5.0-preview.1", Source = "daily" },
                                "Aspire.Hosting.Redis" => new NuGetPackageCli { Id = "Aspire.Hosting.Redis", Version = "9.5.0-preview.1", Source = "daily" },
                                "Aspire.StackExchange.Redis.OutputCaching" => new NuGetPackageCli { Id = "Aspire.StackExchange.Redis.OutputCaching", Version = "9.5.0-preview.1", Source = "daily" },
                                "Microsoft.Extensions.ServiceDiscovery" => new NuGetPackageCli { Id = "Microsoft.Extensions.ServiceDiscovery", Version = "9.5.0-preview.1", Source = "daily" },
                                _ => throw new InvalidOperationException("Unexpected package query."),
                            }
                        };
 
                        return (0, packages.ToArray());
                    },
 
                    GetProjectItemsAndPropertiesAsyncCallback = (projectFile, _, _, _, _) =>
                    {
                        var itemsAndProperties = new JsonObject();
 
                        if (projectFile.FullName == appHostProjectFile.FullName)
                        {
                            itemsAndProperties.WithSdkVersion("9.4.1");
                            itemsAndProperties.WithPackageReference("Aspire.Hosting.AppHost", "9.4.1");
                            itemsAndProperties.WithPackageReference("Aspire.Hosting.Redis", "9.4.1");
                            itemsAndProperties.WithProjectReference(webAppProjectFile.FullName);
                        }
                        else if (projectFile.FullName == webAppProjectFile.FullName)
                        {
                            itemsAndProperties.WithPackageReference("Aspire.StackExchange.Redis.OutputCaching", "9.4.1");
                            itemsAndProperties.WithProjectReference(serviceDefaultsProjectFile.FullName);
                        }
                        else if (projectFile.FullName == serviceDefaultsProjectFile.FullName)
                        {
                            itemsAndProperties.WithPackageReference("Microsoft.Extensions.ServiceDiscovery", "9.4.1");
                        }
                        else
                        {
                            throw new InvalidOperationException("Unexpected project file.");
                        }
 
                        var json = itemsAndProperties.ToJsonString();
                        var document = JsonDocument.Parse(json);
                        return (0, document);
                    },
                    AddPackageAsyncCallback = (_, _, _, _, _, _, _) => 0,
                };
            };
 
            config.InteractionServiceFactory = (s) => new TestInteractionService();
        });
        using var provider = services.BuildServiceProvider();
 
        var packagingService = provider.GetRequiredService<IPackagingService>();
        var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
        var selectedChannel = channels.Single(c => c.Name == "daily");
 
        var projectUpdater = provider.GetRequiredService<IProjectUpdater>();
        var updateResult = await projectUpdater.UpdateProjectAsync(CreateUpdateContext(appHostProjectFile, selectedChannel)).DefaultTimeout();
 
        Assert.True(updateResult.UpdatedApplied);
 
        // Post-condition: the updater MUST NOT have created aspire.config.json. Creating one
        // would silently introduce config the user never asked for and contradict the comment
        // in ProjectUpdater.GetUpdateStepsAsync claiming the rewrite is skipped when the file
        // is absent.
        aspireConfigFile.Refresh();
        Assert.False(aspireConfigFile.Exists, "aspire update must not create aspire.config.json for projects that never had one.");
    }
 
    [Fact]
    public async Task UpdateProjectFileAsync_CanUpdateFromDailyToStableWhereOnePackageIsUnstableOnly()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var srcFolder = workspace.CreateDirectory("src");
 
        var serviceDefaultsFolder = workspace.CreateDirectory("UpdateTester.ServiceDefaults");
        var serviceDefaultsProjectFile = new FileInfo(Path.Combine(serviceDefaultsFolder.FullName, "UpdateTester.ServiceDefaults.csproj"));
 
        var webAppFolder = workspace.CreateDirectory("UpdateTester.WebApp");
        var webAppProjectFile = new FileInfo(Path.Combine(webAppFolder.FullName, "UpdateTester.WebApp.csproj"));
 
        var appHostFolder = workspace.CreateDirectory("UpdateTester.AppHost");
        var appHostProjectFile = new FileInfo(Path.Combine(appHostFolder.FullName, "UpdateTester.AppHost.csproj"));
 
        await File.WriteAllTextAsync(
            appHostProjectFile.FullName,
            $$"""
            <Project Sdk="Microsoft.NET.Sdk">
                <Sdk Name="Aspire.AppHost.Sdk" Version="9.4.1" />
            </Project>
            """);
 
        var aspireConfigFile = new FileInfo(Path.Combine(appHostFolder.FullName, "aspire.config.json"));
        await File.WriteAllTextAsync(
            aspireConfigFile.FullName,
            """
            {
              "appHost": { "path": "UpdateTester.AppHost.csproj" },
              "channel": "daily"
            }
            """);
 
        var packagesAddsExecuted = new List<(FileInfo ProjectFile, string PackageId, string PackageVersion, string? PackageSource, bool NoRestore)>();
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, config =>
        {
            config.DotNetCliRunnerFactory = (sp) =>
            {
                return new TestDotNetCliRunner()
                {
                    SearchPackagesAsyncCallback = (_, query, _, prerelease, _, _, _, _, _, _) =>
                    {
                        var packages = new List<NuGetPackageCli>();
 
                        var matchedPackage = (query, prerelease) switch
                        {
                            { query: "Aspire.AppHost.Sdk", prerelease: false } => new NuGetPackageCli { Id = "Aspire.AppHost.Sdk", Version = "9.4.1", Source = "nuget" },
                            { query: "Aspire.Hosting.AppHost", prerelease: false } => new NuGetPackageCli { Id = "Aspire.Hosting.AppHost", Version = "9.4.1", Source = "nuget" },
                            { query: "Aspire.Hosting.Redis", prerelease: false } => new NuGetPackageCli { Id = "Aspire.Hosting.Redis", Version = "9.4.1", Source = "nuget" },
                            { query: "Aspire.Hosting.Docker", prerelease: true } => new NuGetPackageCli { Id = "Aspire.Hosting.Docker", Version = "9.4.1-preview.1", Source = "nuget" },
                            { query: "Aspire.Hosting.Docker", prerelease: false } => null, // Not in feed.
                            { query: "Aspire.StackExchange.Redis.OutputCaching", prerelease: false } => new NuGetPackageCli { Id = "Aspire.StackExchange.Redis.OutputCaching", Version = "9.4.1", Source = "nuget" },
                            { query: "Microsoft.Extensions.ServiceDiscovery", prerelease: false } => new NuGetPackageCli { Id = "Microsoft.Extensions.ServiceDiscovery", Version = "9.4.1", Source = "nuget" },
                            _ => throw new InvalidOperationException("Unexpected package query."),
                        };
 
                        if (matchedPackage != null)
                        {
                            packages.Add(matchedPackage);
                        }
 
                        return (0, packages.ToArray());
                    },
 
                    GetProjectItemsAndPropertiesAsyncCallback = (projectFile, _, _, _, _) =>
                    {
                        var itemsAndProperties = new JsonObject();
 
                        if (projectFile.FullName == appHostProjectFile.FullName)
                        {
                            itemsAndProperties.WithSdkVersion("9.5.1-preview.1");
                            itemsAndProperties.WithPackageReference("Aspire.Hosting.AppHost", "9.5.1-preview.1");
                            itemsAndProperties.WithPackageReference("Aspire.Hosting.Redis", "9.5.1-preview.1");
                            itemsAndProperties.WithPackageReference("Aspire.Hosting.Docker", "9.5.1-preview.1");
                            itemsAndProperties.WithProjectReference(webAppProjectFile.FullName);
                        }
                        else if (projectFile.FullName == webAppProjectFile.FullName)
                        {
                            itemsAndProperties.WithPackageReference("Aspire.StackExchange.Redis.OutputCaching", "9.5.1-preview.1");
                            itemsAndProperties.WithProjectReference(serviceDefaultsProjectFile.FullName);
                        }
                        else if (projectFile.FullName == serviceDefaultsProjectFile.FullName)
                        {
                            itemsAndProperties.WithPackageReference("Microsoft.Extensions.ServiceDiscovery", "9.5.1-preview.1");
                        }
                        else
                        {
                            throw new InvalidOperationException("Unexpected project file.");
                        }
 
                        var json = itemsAndProperties.ToJsonString();
                        var document = JsonDocument.Parse(json);
                        return (0, document);
                    },
                    // FileInfo, string, string, string?, ProcessInvocationOptions, CancellationToken, int
                    AddPackageAsyncCallback = (projectFile, packageId, packageVersion, source, noRestore, _, _) =>
                    {
                        packagesAddsExecuted.Add((projectFile, packageId, packageVersion, source!, noRestore));
                        return 0;
                    }
                };
            };
 
            config.InteractionServiceFactory = (s) =>
            {
                var interactionService = new TestInteractionService();
                return interactionService;
            };
        });
        using var provider = services.BuildServiceProvider();
 
        // Services we need for project updater.
        var logger = provider.GetRequiredService<ILogger<ProjectUpdater>>();
        var runner = provider.GetRequiredService<IDotNetCliRunner>();
        var interactionService = provider.GetRequiredService<IInteractionService>();
        var cache = provider.GetRequiredService<IMemoryCache>();
        var executionContext = CreateExecutionContext(workspace.WorkspaceRoot);
        var packagingService = provider.GetRequiredService<IPackagingService>();
 
        var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
        var selectedChannel = channels.Single(c => c.Name == "stable");
 
        // If this throws then it means that the updater prompted
        // for confirmation to do an update when no update was required!
        var projectUpdater = provider.GetRequiredService<IProjectUpdater>();
        var updateResult = await projectUpdater.UpdateProjectAsync(CreateUpdateContext(appHostProjectFile, selectedChannel)).DefaultTimeout();
 
        Assert.True(updateResult.UpdatedApplied);
        // Note: Aspire.Hosting.AppHost is not updated because it's removed during SDK migration
        Assert.Collection(
            packagesAddsExecuted,
            item =>
            {
                Assert.Equal("Aspire.Hosting.Redis", item.PackageId);
                Assert.Equal("9.4.1", item.PackageVersion);
                // PackageSource is null because ProjectUpdater passes nugetSource: null to AddPackageAsync.
                Assert.Null(item.PackageSource);
                // Per-package 'dotnet package add' must use --no-restore so the deferred final restore
                // sees a fully-updated reference graph (regression guard for https://github.com/dotnet/aspire/issues/15891).
                Assert.True(item.NoRestore);
                Assert.Equal(appHostProjectFile.FullName, item.ProjectFile.FullName);
            },
            item =>
            {
                Assert.Equal("Aspire.Hosting.Docker", item.PackageId);
                Assert.Equal("9.4.1-preview.1", item.PackageVersion);
                // PackageSource is null because ProjectUpdater passes nugetSource: null to AddPackageAsync.
                Assert.Null(item.PackageSource);
                // Per-package 'dotnet package add' must use --no-restore so the deferred final restore
                // sees a fully-updated reference graph (regression guard for https://github.com/dotnet/aspire/issues/15891).
                Assert.True(item.NoRestore);
                Assert.Equal(appHostProjectFile.FullName, item.ProjectFile.FullName);
            },
            item =>
            {
                Assert.Equal("Aspire.StackExchange.Redis.OutputCaching", item.PackageId);
                Assert.Equal("9.4.1", item.PackageVersion);
                // PackageSource is null because ProjectUpdater passes nugetSource: null to AddPackageAsync.
                Assert.Null(item.PackageSource);
                // Per-package 'dotnet package add' must use --no-restore so the deferred final restore
                // sees a fully-updated reference graph (regression guard for https://github.com/dotnet/aspire/issues/15891).
                Assert.True(item.NoRestore);
                Assert.Equal(webAppProjectFile.FullName, item.ProjectFile.FullName);
            }
        );
 
        var updatedConfig = await File.ReadAllTextAsync(aspireConfigFile.FullName);
        using var configDoc = JsonDocument.Parse(updatedConfig);
        Assert.Equal("daily", configDoc.RootElement.GetProperty("channel").GetString());
    }
 
    [Fact]
    public async Task UpdateProjectFileAsync_PackageNotInChannel_LogsWarningAndContinues()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var appHostFolder = workspace.CreateDirectory("UpdateTester.AppHost");
        var appHostProjectFile = new FileInfo(Path.Combine(appHostFolder.FullName, "UpdateTester.AppHost.csproj"));
 
        await File.WriteAllTextAsync(
            appHostProjectFile.FullName,
            $$"""
            <Project Sdk="Microsoft.NET.Sdk">
                <Sdk Name="Aspire.AppHost.Sdk" Version="9.4.1" />
            </Project>
            """);
 
        var packagesAddsExecuted = new List<(FileInfo ProjectFile, string PackageId, string PackageVersion, string? PackageSource, bool NoRestore)>();
        var testSink = new TestSink();
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, config =>
        {
            config.DotNetCliRunnerFactory = (sp) =>
            {
                return new TestDotNetCliRunner()
                {
                    SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _, _) =>
                    {
                        var packages = new List<NuGetPackageCli>();
 
                        var matchedPackage = query switch
                        {
                            "Aspire.AppHost.Sdk" => new NuGetPackageCli { Id = "Aspire.AppHost.Sdk", Version = "9.5.0-preview.1", Source = "daily" },
                            "Aspire.Hosting.AppHost" => new NuGetPackageCli { Id = "Aspire.Hosting.AppHost", Version = "9.5.0-preview.1", Source = "daily" },
                            "Aspire.Hosting.Redis" => new NuGetPackageCli { Id = "Aspire.Hosting.Redis", Version = "9.5.0-preview.1", Source = "daily" },
                            // Third-party or otherwise unpublished Aspire.Hosting.* package - returns nothing.
                            "Aspire.Hosting.ThirdParty" => null,
                            _ => throw new InvalidOperationException($"Unexpected package query: {query}."),
                        };
 
                        if (matchedPackage is not null)
                        {
                            packages.Add(matchedPackage);
                        }
 
                        return (0, packages.ToArray());
                    },
 
                    GetProjectItemsAndPropertiesAsyncCallback = (projectFile, _, _, _, _) =>
                    {
                        var itemsAndProperties = new JsonObject();
 
                        if (projectFile.FullName == appHostProjectFile.FullName)
                        {
                            itemsAndProperties.WithSdkVersion("9.4.1");
                            itemsAndProperties.WithPackageReference("Aspire.Hosting.AppHost", "9.4.1");
                            itemsAndProperties.WithPackageReference("Aspire.Hosting.Redis", "9.4.1");
                            itemsAndProperties.WithPackageReference("Aspire.Hosting.ThirdParty", "1.0.0");
                        }
                        else
                        {
                            throw new InvalidOperationException("Unexpected project file.");
                        }
 
                        var json = itemsAndProperties.ToJsonString();
                        var document = JsonDocument.Parse(json);
                        return (0, document);
                    },
                    AddPackageAsyncCallback = (projectFile, packageId, packageVersion, source, noRestore, _, _) =>
                    {
                        packagesAddsExecuted.Add((projectFile, packageId, packageVersion, source!, noRestore));
                        return 0;
                    }
                };
            };
 
            config.InteractionServiceFactory = (s) =>
            {
                var interactionService = new TestInteractionService();
                return interactionService;
            };
        });
        services.AddLogging(logging => logging.AddProvider(new TestLoggerProvider(testSink)));
        using var provider = services.BuildServiceProvider();
 
        var packagingService = provider.GetRequiredService<IPackagingService>();
 
        var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
        var selectedChannel = channels.Single(c => c.Name == "daily");
 
        var projectUpdater = provider.GetRequiredService<IProjectUpdater>();
        // Should NOT throw even though Aspire.Hosting.ThirdParty is not in the channel.
        var updateResult = await projectUpdater.UpdateProjectAsync(CreateUpdateContext(appHostProjectFile, selectedChannel)).DefaultTimeout();
 
        Assert.True(updateResult.UpdatedApplied);
        // Aspire.Hosting.Redis should still be updated; Aspire.Hosting.ThirdParty should be skipped with a warning.
        // Aspire.Hosting.AppHost is not updated because it's removed during SDK migration.
        Assert.Collection(
            packagesAddsExecuted,
            item =>
            {
                Assert.Equal("Aspire.Hosting.Redis", item.PackageId);
                Assert.Equal("9.5.0-preview.1", item.PackageVersion);
                Assert.Equal(appHostProjectFile.FullName, item.ProjectFile.FullName);
            }
        );
 
        // A warning should have been logged for the missing package.
        Assert.Contains(
            testSink.Writes,
            w => w.LogLevel == LogLevel.Warning
                && w.Message is not null
                && w.Message.Contains("Aspire.Hosting.ThirdParty")
                && w.Message.Contains("daily"));
    }
 
    [Fact]
    public async Task UpdateProjectFileAsync_DiamondDependency_DoesNotDuplicateUpdates()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        // Create diamond dependency scenario:
        // AppHost -> ProjectA, ProjectB
        // ProjectA -> SharedProject
        // ProjectB -> SharedProject
        // SharedProject has updatable package that should only appear once in update steps
 
        var sharedProjectFolder = workspace.CreateDirectory("SharedProject");
        var sharedProjectFile = new FileInfo(Path.Combine(sharedProjectFolder.FullName, "SharedProject.csproj"));
 
        var projectAFolder = workspace.CreateDirectory("ProjectA");
        var projectAFile = new FileInfo(Path.Combine(projectAFolder.FullName, "ProjectA.csproj"));
 
        var projectBFolder = workspace.CreateDirectory("ProjectB");
        var projectBFile = new FileInfo(Path.Combine(projectBFolder.FullName, "ProjectB.csproj"));
 
        var appHostFolder = workspace.CreateDirectory("DiamondTest.AppHost");
        var appHostProjectFile = new FileInfo(Path.Combine(appHostFolder.FullName, "DiamondTest.AppHost.csproj"));
 
        await File.WriteAllTextAsync(
            appHostProjectFile.FullName,
            """
            <Project Sdk="Microsoft.NET.Sdk">
                <Sdk Name="Aspire.AppHost.Sdk" Version="9.4.1" />
            </Project>
            """);
 
        var packagesAddsExecuted = new List<(FileInfo ProjectFile, string PackageId, string PackageVersion, string? PackageSource, bool NoRestore)>();
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, config =>
        {
            config.DotNetCliRunnerFactory = (sp) =>
            {
                return new TestDotNetCliRunner()
                {
                    SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _, _) =>
                    {
                        var packages = new List<NuGetPackageCli>();
 
                        packages.Add(query switch
                        {
                            "Aspire.AppHost.Sdk" => new NuGetPackageCli { Id = "Aspire.AppHost.Sdk", Version = "9.5.0", Source = "nuget.org" },
                            "Aspire.Hosting.Redis" => new NuGetPackageCli { Id = "Aspire.Hosting.Redis", Version = "9.5.0", Source = "nuget.org" },
                            _ => throw new InvalidOperationException($"Unexpected package query: {query}"),
                        });
 
                        return (0, packages.ToArray());
                    },
 
                    GetProjectItemsAndPropertiesAsyncCallback = (projectFile, _, _, _, _) =>
                    {
                        var itemsAndProperties = new JsonObject();
 
                        if (projectFile.FullName == appHostProjectFile.FullName)
                        {
                            // AppHost references both ProjectA and ProjectB
                            itemsAndProperties.WithSdkVersion("9.4.1");
                            itemsAndProperties.WithPackageReference("Aspire.Hosting.Redis", "9.4.1");
                            itemsAndProperties.WithProjectReference(projectAFile.FullName);
                            itemsAndProperties.WithProjectReference(projectBFile.FullName);
                        }
                        else if (projectFile.FullName == projectAFile.FullName)
                        {
                            // ProjectA references SharedProject - needs empty package reference array
                            itemsAndProperties.WithPackageReference("DummyPackage", "1.0.0"); // Add dummy first to create structure
                            itemsAndProperties["Items"]!["PackageReference"]!.AsArray().Clear(); // Then clear it
                            itemsAndProperties.WithProjectReference(sharedProjectFile.FullName);
                        }
                        else if (projectFile.FullName == projectBFile.FullName)
                        {
                            // ProjectB also references SharedProject (creating the diamond) - needs empty package reference array
                            itemsAndProperties.WithPackageReference("DummyPackage", "1.0.0"); // Add dummy first to create structure
                            itemsAndProperties["Items"]!["PackageReference"]!.AsArray().Clear(); // Then clear it
                            itemsAndProperties.WithProjectReference(sharedProjectFile.FullName);
                        }
                        else if (projectFile.FullName == sharedProjectFile.FullName)
                        {
                            // SharedProject has an updatable package
                            itemsAndProperties.WithPackageReference("Aspire.Hosting.Redis", "9.4.1");
                        }
                        else
                        {
                            throw new InvalidOperationException($"Unexpected project file: {projectFile.FullName}");
                        }
 
                        var json = itemsAndProperties.ToJsonString();
                        var document = JsonDocument.Parse(json);
                        return (0, document);
                    },
 
                    AddPackageAsyncCallback = (projectFile, packageId, packageVersion, source, noRestore, _, _) =>
                    {
                        packagesAddsExecuted.Add((projectFile, packageId, packageVersion, source!, noRestore));
                        return 0;
                    }
                };
            };
 
            config.InteractionServiceFactory = (s) =>
            {
                var interactionService = new TestInteractionService();
                return interactionService;
            };
        });
        using var provider = services.BuildServiceProvider();
 
        var logger = provider.GetRequiredService<ILogger<ProjectUpdater>>();
        var runner = provider.GetRequiredService<IDotNetCliRunner>();
        var interactionService = provider.GetRequiredService<IInteractionService>();
        var cache = provider.GetRequiredService<IMemoryCache>();
        var executionContext = CreateExecutionContext(workspace.WorkspaceRoot);
        var packagingService = provider.GetRequiredService<IPackagingService>();
 
        var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
        var selectedChannel = channels.Single(c => c.Name == "default");
 
        var projectUpdater = provider.GetRequiredService<IProjectUpdater>();
        var updateResult = await projectUpdater.UpdateProjectAsync(CreateUpdateContext(appHostProjectFile, selectedChannel)).DefaultTimeout();
 
        Assert.True(updateResult.UpdatedApplied);
 
        // Verify that the SharedProject's package update only appears once, not twice
        var sharedProjectUpdates = packagesAddsExecuted.Where(p => p.ProjectFile.FullName == sharedProjectFile.FullName).ToList();
        Assert.Single(sharedProjectUpdates);
 
        var sharedProjectUpdate = sharedProjectUpdates.Single();
        Assert.Equal("Aspire.Hosting.Redis", sharedProjectUpdate.PackageId);
        Assert.Equal("9.5.0", sharedProjectUpdate.PackageVersion);
 
        // Should also have the AppHost package update
        var appHostUpdates = packagesAddsExecuted.Where(p => p.ProjectFile.FullName == appHostProjectFile.FullName).ToList();
        Assert.Single(appHostUpdates);
 
        Assert.Equal("Aspire.Hosting.Redis", appHostUpdates.Single().PackageId);
    }
 
    [Fact]
    public async Task UpdateProjectFileAsync_CentralPackageManagement_UpdatesDirectoryPackagesProps()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var serviceDefaultsFolder = workspace.CreateDirectory("UpdateTester.ServiceDefaults");
        var serviceDefaultsProjectFile = new FileInfo(Path.Combine(serviceDefaultsFolder.FullName, "UpdateTester.ServiceDefaults.csproj"));
 
        var appHostFolder = workspace.CreateDirectory("UpdateTester.AppHost");
        var appHostProjectFile = new FileInfo(Path.Combine(appHostFolder.FullName, "UpdateTester.AppHost.csproj"));
 
        var directoryPackagesPropsFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "Directory.Packages.props"));
 
        // Create AppHost project file
        await File.WriteAllTextAsync(
            appHostProjectFile.FullName,
            $$"""
            <Project Sdk="Microsoft.NET.Sdk">
                <Sdk Name="Aspire.AppHost.Sdk" Version="9.5.1" />
            </Project>
            """);
 
        // Create Service Defaults project file without Version attributes (CPM)
        await File.WriteAllTextAsync(
            serviceDefaultsProjectFile.FullName,
            $$"""
            <Project Sdk="Microsoft.NET.Sdk">
                <PropertyGroup>
                    <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
                </PropertyGroup>
                <ItemGroup>
                    <PackageReference Include="Aspire.StackExchange.Redis.OutputCaching" />
                </ItemGroup>
            </Project>
            """);
 
        // Create Directory.Packages.props with outdated versions
        await File.WriteAllTextAsync(
            directoryPackagesPropsFile.FullName,
            $$"""
            <Project>
                <PropertyGroup>
                    <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
                </PropertyGroup>
                <ItemGroup>
                    <PackageVersion Include="Aspire.StackExchange.Redis.OutputCaching" Version="9.4.1" />
                    <PackageVersion Include="Microsoft.Extensions.ServiceDiscovery" Version="9.4.1" />
                </ItemGroup>
            </Project>
            """);
 
        var updatedFiles = new List<string>();
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, config =>
        {
            config.DotNetCliRunnerFactory = (sp) =>
            {
                return new TestDotNetCliRunner()
                {
                    SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _, _) =>
                    {
                        var packages = new List<NuGetPackageCli>();
 
                        packages.Add(query switch
                        {
                            "Aspire.AppHost.Sdk" => new NuGetPackageCli { Id = "Aspire.AppHost.Sdk", Version = "9.5.0", Source = "nuget.org" },
                            "Aspire.Hosting.AppHost" => new NuGetPackageCli { Id = "Aspire.Hosting.AppHost", Version = "9.5.0", Source = "nuget.org" },
                            "Aspire.StackExchange.Redis.OutputCaching" => new NuGetPackageCli { Id = "Aspire.StackExchange.Redis.OutputCaching", Version = "9.5.0", Source = "nuget.org" },
                            "Microsoft.Extensions.ServiceDiscovery" => new NuGetPackageCli { Id = "Microsoft.Extensions.ServiceDiscovery", Version = "9.5.0", Source = "nuget.org" },
                            _ => throw new InvalidOperationException($"Unexpected package query: {query}"),
                        });
 
                        return (0, packages.ToArray());
                    },
                    GetProjectItemsAndPropertiesAsyncCallback = (projectFile, items, properties, options, cancellationToken) =>
                    {
                        var itemsAndProperties = new JsonObject();
 
                        if (projectFile.FullName == appHostProjectFile.FullName)
                        {
                            itemsAndProperties.WithSdkVersion("9.4.1");
                            itemsAndProperties.WithProjectReference(serviceDefaultsProjectFile.FullName);
                        }
                        else if (projectFile.FullName == serviceDefaultsProjectFile.FullName)
                        {
                            // For CPM projects, PackageReference elements don't have Version attributes
                            itemsAndProperties.WithPackageReferenceWithoutVersion("Aspire.StackExchange.Redis.OutputCaching");
                        }
                        else
                        {
                            throw new InvalidOperationException("Unexpected project file.");
                        }
 
                        var json = itemsAndProperties.ToJsonString();
                        var document = JsonDocument.Parse(json);
                        return (0, document);
                    }
                };
            };
 
            config.InteractionServiceFactory = (sp) =>
            {
                var interactionService = new TestInteractionService();
                interactionService.ConfirmCallback = (promptText, defaultValue) =>
                {
                    return true;
                };
 
                return interactionService;
            };
        });
        using var provider = services.BuildServiceProvider();
 
        var logger = provider.GetRequiredService<ILogger<ProjectUpdater>>();
        var runner = provider.GetRequiredService<IDotNetCliRunner>();
        var interactionService = provider.GetRequiredService<IInteractionService>();
        var cache = provider.GetRequiredService<IMemoryCache>();
        var executionContext = CreateExecutionContext(workspace.WorkspaceRoot);
        var packagingService = provider.GetRequiredService<IPackagingService>();
 
        var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
        var selectedChannel = channels.Single(c => c.Name == "default");
 
        var projectUpdater = provider.GetRequiredService<IProjectUpdater>();
        var updateResult = await projectUpdater.UpdateProjectAsync(CreateUpdateContext(appHostProjectFile, selectedChannel)).DefaultTimeout();
 
        Assert.True(updateResult.UpdatedApplied);
 
        // Verify Directory.Packages.props was updated
        var updatedContent = await File.ReadAllTextAsync(directoryPackagesPropsFile.FullName);
        Assert.Contains("Aspire.StackExchange.Redis.OutputCaching\" Version=\"9.5.0\"", updatedContent); // Redis package should be updated
        Assert.DoesNotContain("Aspire.StackExchange.Redis.OutputCaching\" Version=\"9.4.1\"", updatedContent); // Redis package should not contain old version
        // Microsoft.Extensions.ServiceDiscovery should remain unchanged since it's not referenced in any project file
        Assert.Contains("Microsoft.Extensions.ServiceDiscovery\" Version=\"9.4.1\"", updatedContent);
    }
 
    [Fact]
    public async Task UpdateProjectFileAsync_CentralPackageManagement_DetectedByDirectoryPackagesProps()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var appHostFolder = workspace.CreateDirectory("UpdateTester.AppHost");
        var appHostProjectFile = new FileInfo(Path.Combine(appHostFolder.FullName, "UpdateTester.AppHost.csproj"));
 
        var directoryPackagesPropsFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "Directory.Packages.props"));
 
        // Create AppHost project file without ManagePackageVersionsCentrally property
        await File.WriteAllTextAsync(
            appHostProjectFile.FullName,
            $$"""
            <Project Sdk="Microsoft.NET.Sdk">
                <Sdk Name="Aspire.AppHost.Sdk" Version="9.5.1" />
                <ItemGroup>
                    <PackageReference Include="Aspire.Hosting.Redis" />
                </ItemGroup>
            </Project>
            """);
 
        // Create Directory.Packages.props (presence should be detected as CPM)
        await File.WriteAllTextAsync(
            directoryPackagesPropsFile.FullName,
            $$"""
            <Project>
                <PropertyGroup>
                    <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
                </PropertyGroup>
                <ItemGroup>
                    <PackageVersion Include="Aspire.Hosting.Redis" Version="9.4.1" />
                </ItemGroup>
            </Project>
            """);
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, config =>
        {
            config.DotNetCliRunnerFactory = (sp) =>
            {
                return new TestDotNetCliRunner()
                {
                    SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _, _) =>
                    {
                        var packages = new List<NuGetPackageCli>();
 
                        packages.Add(query switch
                        {
                            "Aspire.AppHost.Sdk" => new NuGetPackageCli { Id = "Aspire.AppHost.Sdk", Version = "9.5.0", Source = "nuget.org" },
                            "Aspire.Hosting.AppHost" => new NuGetPackageCli { Id = "Aspire.Hosting.AppHost", Version = "9.5.0", Source = "nuget.org" },
                            "Aspire.Hosting.Redis" => new NuGetPackageCli { Id = "Aspire.Hosting.Redis", Version = "9.5.0", Source = "nuget.org" },
                            _ => throw new InvalidOperationException($"Unexpected package query: {query}"),
                        });
 
                        return (0, packages.ToArray());
                    },
                    GetProjectItemsAndPropertiesAsyncCallback = (projectFile, items, properties, options, cancellationToken) =>
                    {
                        var itemsAndProperties = new JsonObject();
                        itemsAndProperties.WithSdkVersion("9.4.1");
                        itemsAndProperties.WithPackageReferenceWithoutVersion("Aspire.Hosting.Redis");
 
                        var json = itemsAndProperties.ToJsonString();
                        var document = JsonDocument.Parse(json);
                        return (0, document);
                    }
                };
            };
 
            config.InteractionServiceFactory = (sp) =>
            {
                var interactionService = new TestInteractionService();
                interactionService.ConfirmCallback = (promptText, defaultValue) =>
                {
                    return true;
                };
 
                return interactionService;
            };
        });
        using var provider = services.BuildServiceProvider();
 
        var logger = provider.GetRequiredService<ILogger<ProjectUpdater>>();
        var runner = provider.GetRequiredService<IDotNetCliRunner>();
        var interactionService = provider.GetRequiredService<IInteractionService>();
        var cache = provider.GetRequiredService<IMemoryCache>();
        var executionContext = CreateExecutionContext(workspace.WorkspaceRoot);
        var packagingService = provider.GetRequiredService<IPackagingService>();
 
        var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
        var selectedChannel = channels.Single(c => c.Name == "default");
 
        var projectUpdater = provider.GetRequiredService<IProjectUpdater>();
        var updateResult = await projectUpdater.UpdateProjectAsync(CreateUpdateContext(appHostProjectFile, selectedChannel)).DefaultTimeout();
 
        Assert.True(updateResult.UpdatedApplied);
 
        // Verify Directory.Packages.props was updated
        var updatedContent = await File.ReadAllTextAsync(directoryPackagesPropsFile.FullName);
        Assert.Contains("Version=\"9.5.0\"", updatedContent);
    }
 
    [Fact]
    public async Task UpdateProjectFileAsync_CentralPackageManagement_PackageNotInDirectoryPackagesProps()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var appHostFolder = workspace.CreateDirectory("UpdateTester.AppHost");
        var appHostProjectFile = new FileInfo(Path.Combine(appHostFolder.FullName, "UpdateTester.AppHost.csproj"));
 
        var directoryPackagesPropsFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "Directory.Packages.props"));
 
        // Create AppHost project file
        await File.WriteAllTextAsync(
            appHostProjectFile.FullName,
            $$"""
            <Project Sdk="Microsoft.NET.Sdk">
                <Sdk Name="Aspire.AppHost.Sdk" Version="9.5.1" />
                <ItemGroup>
                    <PackageReference Include="Aspire.Hosting.Redis" />
                </ItemGroup>
            </Project>
            """);
 
        // Create Directory.Packages.props without the package (should be skipped)
        await File.WriteAllTextAsync(
            directoryPackagesPropsFile.FullName,
            $$"""
            <Project>
                <PropertyGroup>
                    <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
                </PropertyGroup>
                <ItemGroup>
                    <!-- Package not included here -->
                </ItemGroup>
            </Project>
            """);
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, config =>
        {
            config.DotNetCliRunnerFactory = (sp) =>
            {
                return new TestDotNetCliRunner()
                {
                    SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _, _) =>
                    {
                        var packages = new List<NuGetPackageCli>();
 
                        packages.Add(query switch
                        {
                            "Aspire.AppHost.Sdk" => new NuGetPackageCli { Id = "Aspire.AppHost.Sdk", Version = "9.5.0", Source = "nuget.org" },
                            "Aspire.Hosting.AppHost" => new NuGetPackageCli { Id = "Aspire.Hosting.AppHost", Version = "9.5.0", Source = "nuget.org" },
                            "Aspire.Hosting.Redis" => new NuGetPackageCli { Id = "Aspire.Hosting.Redis", Version = "9.5.0", Source = "nuget.org" },
                            _ => throw new InvalidOperationException($"Unexpected package query: {query}"),
                        });
 
                        return (0, packages.ToArray());
                    },
                    GetProjectItemsAndPropertiesAsyncCallback = (projectFile, items, properties, options, cancellationToken) =>
                    {
                        var itemsAndProperties = new JsonObject();
                        itemsAndProperties.WithSdkVersion("9.5.0"); // Already up to date
                        itemsAndProperties.WithPackageReferenceWithoutVersion("Aspire.Hosting.Redis");
 
                        var json = itemsAndProperties.ToJsonString();
                        var document = JsonDocument.Parse(json);
                        return (0, document);
                    }
                };
            };
 
            config.InteractionServiceFactory = (sp) =>
            {
                var interactionService = new TestInteractionService();
                interactionService.ConfirmCallback = (promptText, defaultValue) =>
                {
                    // Should not be called since no updates are needed
                    throw new InvalidOperationException("Should not prompt when no work required.");
                };
 
                return interactionService;
            };
        });
        using var provider = services.BuildServiceProvider();
 
        var logger = provider.GetRequiredService<ILogger<ProjectUpdater>>();
        var runner = provider.GetRequiredService<IDotNetCliRunner>();
        var interactionService = provider.GetRequiredService<IInteractionService>();
        var cache = provider.GetRequiredService<IMemoryCache>();
        var executionContext = CreateExecutionContext(workspace.WorkspaceRoot);
        var packagingService = provider.GetRequiredService<IPackagingService>();
 
        var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
        var selectedChannel = channels.Single(c => c.Name == "default");
 
        var projectUpdater = provider.GetRequiredService<IProjectUpdater>();
        var updateResult = await projectUpdater.UpdateProjectAsync(CreateUpdateContext(appHostProjectFile, selectedChannel)).DefaultTimeout();
 
        Assert.False(updateResult.UpdatedApplied);
 
        // Verify Directory.Packages.props was not modified
        var content = await File.ReadAllTextAsync(directoryPackagesPropsFile.FullName);
        Assert.DoesNotContain("Aspire.Hosting.Redis", content);
    }
 
    [Fact]
    public async Task UpdateProjectFileAsync_CpmOrphanAppHostPackageVersion_RemovedEvenWhenSdkAlreadyCurrent()
    {
        // Reproduces https://github.com/microsoft/aspire/issues/15476.
        //
        // PR #14585 fixed the migration path (old <Sdk Name="..."/> element ->
        // inline <Project Sdk="Aspire.AppHost.Sdk/x"> attribute) by removing any
        // orphan PackageVersion entry for Aspire.Hosting.AppHost from
        // Directory.Packages.props as part of the SDK update step.
        //
        // But that cleanup only runs when AnalyzeAppHostSdkAsync decides the SDK
        // version itself needs to change. When the AppHost is already on the
        // latest stable SDK using the new format, the analyzer early-returns and
        // the orphan PackageVersion is left in place - which then breaks
        // dotnet restore with NU1009 because the new SDK implicitly defines the
        // matching PackageReference.
        //
        // This test pins the SDK version to the same value the search returns,
        // so AnalyzeAppHostSdkAsync sees "App Host SDK is up to date" and skips
        // the SDK update step. The updater must still detect the orphan
        // PackageVersion and remove it.
 
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var appHostFolder = workspace.CreateDirectory("UpdateTester.AppHost");
        var appHostProjectFile = new FileInfo(Path.Combine(appHostFolder.FullName, "UpdateTester.AppHost.csproj"));
 
        var directoryPackagesPropsFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "Directory.Packages.props"));
 
        // AppHost is already on the new SDK format pinned to the latest stable
        // version (matches what SearchPackagesAsyncCallback returns below).
        await File.WriteAllTextAsync(
            appHostProjectFile.FullName,
            """
            <Project Sdk="Aspire.AppHost.Sdk/9.5.0">
              <PropertyGroup>
                <OutputType>Exe</OutputType>
                <TargetFramework>net9.0</TargetFramework>
                <IsAspireHost>true</IsAspireHost>
              </PropertyGroup>
            </Project>
            """);
 
        // Directory.Packages.props with CPM enabled and an orphan PackageVersion
        // entry for Aspire.Hosting.AppHost. NuGet rejects this with NU1009 when
        // the new SDK implicitly adds the matching PackageReference.
        await File.WriteAllTextAsync(
            directoryPackagesPropsFile.FullName,
            """
            <Project>
              <PropertyGroup>
                <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
              </PropertyGroup>
              <ItemGroup>
                <PackageVersion Include="Aspire.Hosting.AppHost" Version="9.5.0" />
              </ItemGroup>
            </Project>
            """);
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, config =>
        {
            config.DotNetCliRunnerFactory = (sp) =>
            {
                return new TestDotNetCliRunner()
                {
                    SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _, _) =>
                    {
                        var packages = new List<NuGetPackageCli>
                        {
                            query switch
                            {
                                "Aspire.AppHost.Sdk" => new NuGetPackageCli { Id = "Aspire.AppHost.Sdk", Version = "9.5.0", Source = "nuget.org" },
                                "Aspire.Hosting.AppHost" => new NuGetPackageCli { Id = "Aspire.Hosting.AppHost", Version = "9.5.0", Source = "nuget.org" },
                                _ => throw new InvalidOperationException($"Unexpected package query: {query}"),
                            }
                        };
 
                        return (0, packages.ToArray());
                    },
                    GetProjectItemsAndPropertiesAsyncCallback = (projectFile, _, _, _, _) =>
                    {
                        var itemsAndProperties = new JsonObject();
 
                        if (projectFile.FullName == appHostProjectFile.FullName)
                        {
                            // SDK version already matches latest -> AnalyzeAppHostSdkAsync
                            // will log "App Host SDK is up to date" and skip the SDK
                            // update step.
                            itemsAndProperties.WithSdkVersion("9.5.0");
                        }
                        else
                        {
                            throw new InvalidOperationException("Unexpected project file.");
                        }
 
                        var json = itemsAndProperties.ToJsonString();
                        var document = JsonDocument.Parse(json);
                        return (0, document);
                    }
                };
            };
 
            config.InteractionServiceFactory = (sp) =>
            {
                var interactionService = new TestInteractionService();
                interactionService.ConfirmCallback = (_, _) => true;
                return interactionService;
            };
        });
        using var provider = services.BuildServiceProvider();
 
        var executionContext = CreateExecutionContext(workspace.WorkspaceRoot);
        var packagingService = provider.GetRequiredService<IPackagingService>();
        var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
        var selectedChannel = channels.Single(c => c.Name == "default");
 
        var projectUpdater = provider.GetRequiredService<IProjectUpdater>();
        await projectUpdater.UpdateProjectAsync(CreateUpdateContext(appHostProjectFile, selectedChannel)).DefaultTimeout();
 
        // The orphan PackageVersion for Aspire.Hosting.AppHost must be removed
        // even when the SDK version itself was already current.
        var updatedContent = await File.ReadAllTextAsync(directoryPackagesPropsFile.FullName);
        Assert.DoesNotContain("Aspire.Hosting.AppHost", updatedContent);
    }
 
    [Fact]
    public async Task UpdateProjectFileAsync_StaleAppHostPackageReference_RemovedEvenWhenSdkAlreadyCurrent()
    {
        // Companion to UpdateProjectFileAsync_CpmOrphanAppHostPackageVersion_RemovedEvenWhenSdkAlreadyCurrent.
        // The new SDK implicitly defines Aspire.Hosting.AppHost, so an explicit
        // PackageReference is now redundant and must be removed even when no
        // SDK version bump is required. No CPM is involved here.
 
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var appHostFolder = workspace.CreateDirectory("UpdateTester.AppHost");
        var appHostProjectFile = new FileInfo(Path.Combine(appHostFolder.FullName, "UpdateTester.AppHost.csproj"));
 
        await File.WriteAllTextAsync(
            appHostProjectFile.FullName,
            """
            <Project Sdk="Aspire.AppHost.Sdk/9.5.0">
              <PropertyGroup>
                <OutputType>Exe</OutputType>
                <TargetFramework>net9.0</TargetFramework>
                <IsAspireHost>true</IsAspireHost>
              </PropertyGroup>
              <ItemGroup>
                <PackageReference Include="Aspire.Hosting.AppHost" Version="9.5.0" />
              </ItemGroup>
            </Project>
            """);
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, config =>
        {
            config.DotNetCliRunnerFactory = (sp) =>
            {
                return new TestDotNetCliRunner()
                {
                    SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _, _) =>
                    {
                        var packages = new List<NuGetPackageCli>
                        {
                            query switch
                            {
                                "Aspire.AppHost.Sdk" => new NuGetPackageCli { Id = "Aspire.AppHost.Sdk", Version = "9.5.0", Source = "nuget.org" },
                                "Aspire.Hosting.AppHost" => new NuGetPackageCli { Id = "Aspire.Hosting.AppHost", Version = "9.5.0", Source = "nuget.org" },
                                _ => throw new InvalidOperationException($"Unexpected package query: {query}"),
                            }
                        };
 
                        return (0, packages.ToArray());
                    },
                    GetProjectItemsAndPropertiesAsyncCallback = (projectFile, _, _, _, _) =>
                    {
                        var itemsAndProperties = new JsonObject();
                        itemsAndProperties.WithSdkVersion("9.5.0");
                        var json = itemsAndProperties.ToJsonString();
                        var document = JsonDocument.Parse(json);
                        return (0, document);
                    }
                };
            };
 
            config.InteractionServiceFactory = (sp) =>
            {
                var interactionService = new TestInteractionService();
                interactionService.ConfirmCallback = (_, _) => true;
                return interactionService;
            };
        });
        using var provider = services.BuildServiceProvider();
 
        var executionContext = CreateExecutionContext(workspace.WorkspaceRoot);
        var packagingService = provider.GetRequiredService<IPackagingService>();
        var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
        var selectedChannel = channels.Single(c => c.Name == "default");
 
        var projectUpdater = provider.GetRequiredService<IProjectUpdater>();
        await projectUpdater.UpdateProjectAsync(CreateUpdateContext(appHostProjectFile, selectedChannel)).DefaultTimeout();
 
        var updatedContent = await File.ReadAllTextAsync(appHostProjectFile.FullName);
        Assert.DoesNotContain("Aspire.Hosting.AppHost", updatedContent);
    }
 
    [Fact]
    public async Task UpdateProjectFileAsync_NoLegacyAppHostReferences_NoChangesWhenSdkAlreadyCurrent()
    {
        // Negative case: a clean, fully-migrated AppHost on the latest SDK with
        // no orphan references must not be touched. Guards against the new
        // cleanup path enqueuing spurious update steps.
 
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var appHostFolder = workspace.CreateDirectory("UpdateTester.AppHost");
        var appHostProjectFile = new FileInfo(Path.Combine(appHostFolder.FullName, "UpdateTester.AppHost.csproj"));
 
        const string ProjectContent = """
            <Project Sdk="Aspire.AppHost.Sdk/9.5.0">
              <PropertyGroup>
                <OutputType>Exe</OutputType>
                <TargetFramework>net9.0</TargetFramework>
                <IsAspireHost>true</IsAspireHost>
              </PropertyGroup>
            </Project>
            """;
        await File.WriteAllTextAsync(appHostProjectFile.FullName, ProjectContent);
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, config =>
        {
            config.DotNetCliRunnerFactory = (sp) =>
            {
                return new TestDotNetCliRunner()
                {
                    SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _, _) =>
                    {
                        var packages = new List<NuGetPackageCli>
                        {
                            query switch
                            {
                                "Aspire.AppHost.Sdk" => new NuGetPackageCli { Id = "Aspire.AppHost.Sdk", Version = "9.5.0", Source = "nuget.org" },
                                _ => throw new InvalidOperationException($"Unexpected package query: {query}"),
                            }
                        };
 
                        return (0, packages.ToArray());
                    },
                    GetProjectItemsAndPropertiesAsyncCallback = (projectFile, _, _, _, _) =>
                    {
                        var itemsAndProperties = new JsonObject();
                        itemsAndProperties.WithSdkVersion("9.5.0");
                        var json = itemsAndProperties.ToJsonString();
                        var document = JsonDocument.Parse(json);
                        return (0, document);
                    }
                };
            };
 
            config.InteractionServiceFactory = (sp) =>
            {
                var interactionService = new TestInteractionService();
                interactionService.ConfirmCallback = (_, _) => true;
                return interactionService;
            };
        });
        using var provider = services.BuildServiceProvider();
 
        var executionContext = CreateExecutionContext(workspace.WorkspaceRoot);
        var packagingService = provider.GetRequiredService<IPackagingService>();
        var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
        var selectedChannel = channels.Single(c => c.Name == "default");
 
        var projectUpdater = provider.GetRequiredService<IProjectUpdater>();
        await projectUpdater.UpdateProjectAsync(CreateUpdateContext(appHostProjectFile, selectedChannel)).DefaultTimeout();
 
        var unchangedContent = await File.ReadAllTextAsync(appHostProjectFile.FullName);
        Assert.Equal(ProjectContent, unchangedContent);
    }
 
    [Fact]
    public async Task UpdateProjectFileAsync_LegacyAppHostReferencesWithMixedCasing_StillRemoved()
    {
        // NuGet package IDs are case-insensitive, so `aspire.hosting.apphost` and
        // `Aspire.Hosting.AppHost` refer to the same package. The cleanup XPaths
        // must match the @Include attribute case-insensitively or differently
        // cased entries authored by users (or generated by external tooling)
        // will silently survive the cleanup and break the next restore with
        // NU1009.
 
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var appHostFolder = workspace.CreateDirectory("UpdateTester.AppHost");
        var appHostProjectFile = new FileInfo(Path.Combine(appHostFolder.FullName, "UpdateTester.AppHost.csproj"));
        var directoryPackagesPropsFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "Directory.Packages.props"));
 
        await File.WriteAllTextAsync(
            appHostProjectFile.FullName,
            """
            <Project Sdk="Aspire.AppHost.Sdk/9.5.0">
              <PropertyGroup>
                <OutputType>Exe</OutputType>
                <TargetFramework>net9.0</TargetFramework>
                <IsAspireHost>true</IsAspireHost>
              </PropertyGroup>
              <ItemGroup>
                <PackageReference Include="aspire.hosting.apphost" />
              </ItemGroup>
            </Project>
            """);
 
        await File.WriteAllTextAsync(
            directoryPackagesPropsFile.FullName,
            """
            <Project>
              <PropertyGroup>
                <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
              </PropertyGroup>
              <ItemGroup>
                <PackageVersion Include="ASPIRE.HOSTING.APPHOST" Version="9.5.0" />
              </ItemGroup>
            </Project>
            """);
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, config =>
        {
            config.DotNetCliRunnerFactory = (sp) =>
            {
                return new TestDotNetCliRunner()
                {
                    SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _, _) =>
                    {
                        var packages = new List<NuGetPackageCli>
                        {
                            query switch
                            {
                                "Aspire.AppHost.Sdk" => new NuGetPackageCli { Id = "Aspire.AppHost.Sdk", Version = "9.5.0", Source = "nuget.org" },
                                "Aspire.Hosting.AppHost" => new NuGetPackageCli { Id = "Aspire.Hosting.AppHost", Version = "9.5.0", Source = "nuget.org" },
                                _ => throw new InvalidOperationException($"Unexpected package query: {query}"),
                            }
                        };
 
                        return (0, packages.ToArray());
                    },
                    GetProjectItemsAndPropertiesAsyncCallback = (projectFile, _, _, _, _) =>
                    {
                        var itemsAndProperties = new JsonObject();
                        itemsAndProperties.WithSdkVersion("9.5.0");
                        var json = itemsAndProperties.ToJsonString();
                        var document = JsonDocument.Parse(json);
                        return (0, document);
                    }
                };
            };
 
            config.InteractionServiceFactory = (sp) =>
            {
                var interactionService = new TestInteractionService();
                interactionService.ConfirmCallback = (_, _) => true;
                return interactionService;
            };
        });
        using var provider = services.BuildServiceProvider();
 
        var packagingService = provider.GetRequiredService<IPackagingService>();
        var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
        var selectedChannel = channels.Single(c => c.Name == "default");
 
        var projectUpdater = provider.GetRequiredService<IProjectUpdater>();
        await projectUpdater.UpdateProjectAsync(CreateUpdateContext(appHostProjectFile, selectedChannel)).DefaultTimeout();
 
        var updatedProject = await File.ReadAllTextAsync(appHostProjectFile.FullName);
        var updatedProps = await File.ReadAllTextAsync(directoryPackagesPropsFile.FullName);
        Assert.DoesNotContain("<PackageReference", updatedProject, StringComparison.Ordinal);
        Assert.DoesNotContain("<PackageVersion", updatedProps, StringComparison.Ordinal);
    }
 
    [Fact]
    public async Task UpdateProjectAsync_StableChannel_DoesNotCreateNuGetConfigWhenNoneExists()
    {
        // When updating to the stable channel (maps Aspire* to nuget.org, so no config is
        // needed) and no project-local nuget.config exists, the updater should NOT create one.
        // See: https://github.com/microsoft/aspire/issues/18124
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var (appHostProjectFile, _) = await SetupNuGetConfigTestProject(workspace);
 
        var services = CreateNuGetConfigTestServices(workspace, "9.5.0", "nuget.org");
        using var provider = services.BuildServiceProvider();
 
        var channels = await provider.GetRequiredService<IPackagingService>().GetChannelsAsync().DefaultTimeout();
        var projectUpdater = provider.GetRequiredService<IProjectUpdater>();
        await projectUpdater.UpdateProjectAsync(CreateUpdateContext(appHostProjectFile, channels.Single(c => c.Name == "stable"))).DefaultTimeout();
 
        Assert.False(File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, "nuget.config")));
        Assert.False(File.Exists(Path.Combine(appHostProjectFile.DirectoryName!, "nuget.config")));
    }
 
    [Fact]
    public async Task UpdateProjectAsync_StableChannel_UpdatesExistingNuGetConfig()
    {
        // When updating to the stable channel and a project-local nuget.config already
        // exists (e.g. from a previous daily channel), the updater should update it to
        // clean up old feeds.
        // See: https://github.com/microsoft/aspire/issues/18124
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var (appHostProjectFile, _) = await SetupNuGetConfigTestProject(workspace);
 
        // Pre-existing nuget.config from a previous daily channel
        var nugetConfigPath = Path.Combine(workspace.WorkspaceRoot.FullName, "nuget.config");
        await File.WriteAllTextAsync(nugetConfigPath,
            """
            <?xml version="1.0" encoding="utf-8"?>
            <configuration>
              <packageSources>
                <add key="aspire-daily" value="https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet9/nuget/v3/index.json" />
                <add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
              </packageSources>
              <packageSourceMapping>
                <packageSource key="aspire-daily">
                  <package pattern="Aspire*" />
                </packageSource>
                <packageSource key="nuget.org">
                  <package pattern="*" />
                </packageSource>
              </packageSourceMapping>
            </configuration>
            """);
 
        var services = CreateNuGetConfigTestServices(workspace, "9.5.0", "nuget.org", getNuGetConfigPathsCallback: (_, _, _) =>
        {
            return (0, new[]
            {
                nugetConfigPath,
                Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "NuGet", "NuGet.Config")
            });
        });
        using var provider = services.BuildServiceProvider();
 
        var channels = await provider.GetRequiredService<IPackagingService>().GetChannelsAsync().DefaultTimeout();
        var projectUpdater = provider.GetRequiredService<IProjectUpdater>();
        await projectUpdater.UpdateProjectAsync(CreateUpdateContext(appHostProjectFile, channels.Single(c => c.Name == "stable"))).DefaultTimeout();
 
        Assert.True(File.Exists(nugetConfigPath));
        var updatedConfig = await File.ReadAllTextAsync(nugetConfigPath);
        Assert.Contains("https://api.nuget.org/v3/index.json", updatedConfig);
    }
 
    [Fact]
    public async Task UpdateProjectAsync_DailyChannel_CreatesNuGetConfigWhenNoneExists()
    {
        // Contrast: when updating to the daily channel (routes Aspire* to a custom feed),
        // the updater should create a nuget.config if none exists.
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var (appHostProjectFile, _) = await SetupNuGetConfigTestProject(workspace);
 
        var services = CreateNuGetConfigTestServices(workspace, "9.5.0-preview.1", "daily");
        using var provider = services.BuildServiceProvider();
 
        var channels = await provider.GetRequiredService<IPackagingService>().GetChannelsAsync().DefaultTimeout();
        var projectUpdater = provider.GetRequiredService<IProjectUpdater>();
        await projectUpdater.UpdateProjectAsync(CreateUpdateContext(appHostProjectFile, channels.Single(c => c.Name == "daily"))).DefaultTimeout();
 
        Assert.True(File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, "nuget.config")));
    }
 
    private static async Task<(FileInfo AppHostProjectFile, DirectoryInfo AppHostFolder)> SetupNuGetConfigTestProject(TemporaryWorkspace workspace)
    {
        var appHostFolder = workspace.CreateDirectory("UpdateTester.AppHost");
        var appHostProjectFile = new FileInfo(Path.Combine(appHostFolder.FullName, "UpdateTester.AppHost.csproj"));
 
        await File.WriteAllTextAsync(
            appHostProjectFile.FullName,
            """
            <Project Sdk="Microsoft.NET.Sdk">
                <Sdk Name="Aspire.AppHost.Sdk" Version="9.4.1" />
            </Project>
            """);
 
        return (appHostProjectFile, appHostFolder);
    }
 
    private IServiceCollection CreateNuGetConfigTestServices(
        TemporaryWorkspace workspace,
        string targetVersion,
        string source,
        Func<DirectoryInfo, ProcessInvocationOptions, CancellationToken, (int, string[])>? getNuGetConfigPathsCallback = null)
    {
        return CliTestHelper.CreateServiceCollection(workspace, outputHelper, config =>
        {
            config.DotNetCliRunnerFactory = (sp) =>
            {
                var runner = new TestDotNetCliRunner()
                {
                    SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _, _) =>
                    {
                        var packages = new List<NuGetPackageCli>
                        {
                            query switch
                            {
                                "Aspire.AppHost.Sdk" => new NuGetPackageCli { Id = "Aspire.AppHost.Sdk", Version = targetVersion, Source = source },
                                "Aspire.Hosting.AppHost" => new NuGetPackageCli { Id = "Aspire.Hosting.AppHost", Version = targetVersion, Source = source },
                                _ => throw new InvalidOperationException("Unexpected package query."),
                            }
                        };
                        return (0, packages.ToArray());
                    },
 
                    GetProjectItemsAndPropertiesAsyncCallback = (projectFile, _, _, _, _) =>
                    {
                        var itemsAndProperties = new JsonObject();
                        itemsAndProperties.WithSdkVersion("9.4.1");
                        itemsAndProperties.WithPackageReference("Aspire.Hosting.AppHost", "9.4.1");
 
                        var json = itemsAndProperties.ToJsonString();
                        var document = JsonDocument.Parse(json);
                        return (0, document);
                    },
 
                    AddPackageAsyncCallback = (_, _, _, _, _, _, _) => 0
                };
 
                if (getNuGetConfigPathsCallback is not null)
                {
                    runner.GetNuGetConfigPathsAsyncCallback = getNuGetConfigPathsCallback;
                }
 
                return runner;
            };
 
            config.InteractionServiceFactory = (s) => new TestInteractionService();
        });
    }
 
    private static UpdatePackagesContext CreateUpdateContext(FileInfo appHostFile, PackageChannel channel) =>
        new()
        {
            AppHostFile = appHostFile,
            Channel = channel,
            ConfirmBinding = PromptBinding.CreateDefault(true),
            NuGetConfigDirBinding = PromptBinding.CreateDefault<string?>(null),
        };
 
    private static Aspire.Cli.CliExecutionContext CreateExecutionContext(DirectoryInfo workingDirectory)
    {
        return TestExecutionContextHelper.CreateExecutionContext(workingDirectory);
    }
 
    [Fact]
    public void PackageUpdateStep_GetFormattedDisplayText_ReturnsFormattedString()
    {
        // Arrange
        var projectFile = new FileInfo("/path/to/MyProject.csproj");
        var packageStep = new PackageUpdateStep(
            "Update package Aspire.Hosting.Redis from 9.0.0 to 9.1.0",
            () => Task.CompletedTask,
            "Aspire.Hosting.Redis",
            "9.0.0",
            "9.1.0",
            projectFile);
 
        // Act
        var formattedText = packageStep.GetFormattedDisplayText();
 
        // Assert
        Assert.Equal("[bold yellow]Aspire.Hosting.Redis[/] [bold green]9.0.0[/] to [bold green]9.1.0[/]", formattedText);
    }
 
    [Fact]
    public void ProjectConfigUpdateStep_GetFormattedDisplayText_ReturnsFormattedString_WithExistingValues()
    {
        var step = new ProjectConfigUpdateStep(
            "Update aspire.config.json channel from 'pr-17452' to 'stable'",
            () => Task.CompletedTask,
            "pr-17452",
            "stable",
            "13.4.0",
            "13.5.0");
 
        Assert.Equal(
            "[bold yellow]aspire.config.json#channel[/] [bold green]pr-17452[/] to [bold green]stable[/], [bold yellow]aspire.config.json#sdk.version[/] [bold green]13.4.0[/] to [bold green]13.5.0[/]",
            step.GetFormattedDisplayText());
    }
 
    [Fact]
    public void ProjectConfigUpdateStep_GetFormattedDisplayText_ReturnsFormattedString_WhenValuesAbsent()
    {
        var step = new ProjectConfigUpdateStep(
            "Update aspire.config.json channel from '(none)' to 'stable'",
            () => Task.CompletedTask,
            CurrentChannel: null,
            NewChannel: "stable",
            CurrentSdkVersion: null,
            NewSdkVersion: "13.5.0");
 
        Assert.Equal(
            "[bold yellow]aspire.config.json#channel[/] [grey](none)[/] to [bold green]stable[/], [bold yellow]aspire.config.json#sdk.version[/] [grey](unknown)[/] to [bold green]13.5.0[/]",
            step.GetFormattedDisplayText());
    }
 
    [Fact]
    public async Task UpdateProjectFileAsync_CentralPackageManagement_ResolvesAspireVersionProperty()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var appHostFolder = workspace.CreateDirectory("UpdateTester.AppHost");
        var appHostProjectFile = new FileInfo(Path.Combine(appHostFolder.FullName, "UpdateTester.AppHost.csproj"));
 
        var directoryPackagesPropsFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "Directory.Packages.props"));
 
        // Create AppHost project file
        await File.WriteAllTextAsync(
            appHostProjectFile.FullName,
            $$"""
            <Project Sdk="Microsoft.NET.Sdk">
                <Sdk Name="Aspire.AppHost.Sdk" Version="9.5.1" />
                <ItemGroup>
                    <PackageReference Include="Aspire.Hosting.Redis" />
                </ItemGroup>
            </Project>
            """);
 
        // Create Directory.Packages.props with MSBuild property expression
        await File.WriteAllTextAsync(
            directoryPackagesPropsFile.FullName,
            $$"""
            <Project>
                <PropertyGroup>
                    <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
                    <AspireVersion>9.4.1</AspireVersion>
                </PropertyGroup>
                <ItemGroup>
                    <PackageVersion Include="Aspire.Hosting.Redis" Version="$(AspireVersion)" />
                </ItemGroup>
            </Project>
            """);
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, config =>
        {
            config.DotNetCliRunnerFactory = (sp) =>
            {
                return new TestDotNetCliRunner()
                {
                    SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _, _) =>
                    {
                        var packages = new List<NuGetPackageCli>();
 
                        packages.Add(query switch
                        {
                            "Aspire.AppHost.Sdk" => new NuGetPackageCli { Id = "Aspire.AppHost.Sdk", Version = "9.5.0", Source = "nuget.org" },
                            "Aspire.Hosting.Redis" => new NuGetPackageCli { Id = "Aspire.Hosting.Redis", Version = "9.5.0", Source = "nuget.org" },
                            _ => throw new InvalidOperationException($"Unexpected package query: {query}"),
                        });
 
                        return (0, packages.ToArray());
                    },
                    GetProjectItemsAndPropertiesAsyncCallback = (projectFile, items, properties, options, cancellationToken) =>
                    {
                        var itemsAndProperties = new JsonObject();
 
                        // For SDK version queries
                        if (properties.Contains("AspireHostingSDKVersion"))
                        {
                            itemsAndProperties.WithSdkVersion("9.4.1");
                            itemsAndProperties.WithPackageReferenceWithoutVersion("Aspire.Hosting.Redis");
                        }
 
                        // For property resolution queries
                        if (properties.Contains("AspireVersion"))
                        {
                            itemsAndProperties.WithProperty("AspireVersion", "9.4.1");
                        }
 
                        var json = itemsAndProperties.ToJsonString();
                        var document = JsonDocument.Parse(json);
                        return (0, document);
                    }
                };
            };
 
            config.InteractionServiceFactory = (sp) =>
            {
                var interactionService = new TestInteractionService();
                interactionService.ConfirmCallback = (promptText, defaultValue) =>
                {
                    return true;
                };
 
                return interactionService;
            };
        });
        using var provider = services.BuildServiceProvider();
 
        var logger = provider.GetRequiredService<ILogger<ProjectUpdater>>();
        var runner = provider.GetRequiredService<IDotNetCliRunner>();
        var interactionService = provider.GetRequiredService<IInteractionService>();
        var cache = provider.GetRequiredService<IMemoryCache>();
        var executionContext = CreateExecutionContext(workspace.WorkspaceRoot);
        var packagingService = provider.GetRequiredService<IPackagingService>();
 
        var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
        var selectedChannel = channels.Single(c => c.Name == "default");
 
        var projectUpdater = provider.GetRequiredService<IProjectUpdater>();
        var updateResult = await projectUpdater.UpdateProjectAsync(CreateUpdateContext(appHostProjectFile, selectedChannel)).DefaultTimeout();
 
        Assert.True(updateResult.UpdatedApplied);
 
        // Verify Directory.Packages.props was updated with new version
        var content = await File.ReadAllTextAsync(directoryPackagesPropsFile.FullName);
        Assert.Contains("<PackageVersion Include=\"Aspire.Hosting.Redis\" Version=\"9.5.0\" />", content);
    }
 
    [Fact]
    public async Task UpdateProjectFileAsync_CentralPackageManagement_ResolvesMultipleProperties()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var appHostFolder = workspace.CreateDirectory("UpdateTester.AppHost");
        var appHostProjectFile = new FileInfo(Path.Combine(appHostFolder.FullName, "UpdateTester.AppHost.csproj"));
 
        var directoryPackagesPropsFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "Directory.Packages.props"));
 
        // Create AppHost project file
        await File.WriteAllTextAsync(
            appHostProjectFile.FullName,
            $$"""
            <Project Sdk="Microsoft.NET.Sdk">
                <Sdk Name="Aspire.AppHost.Sdk" Version="9.5.1" />
                <ItemGroup>
                    <PackageReference Include="Aspire.Hosting.Redis" />
                    <PackageReference Include="Aspire.StackExchange.Redis" />
                </ItemGroup>
            </Project>
            """);
 
        // Create Directory.Packages.props with multiple MSBuild property expressions
        await File.WriteAllTextAsync(
            directoryPackagesPropsFile.FullName,
            $$"""
            <Project>
                <PropertyGroup>
                    <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
                    <AspireVersion>9.4.1</AspireVersion>
                    <AspireUnstableVersion>9.4.1-preview.1</AspireUnstableVersion>
                </PropertyGroup>
                <ItemGroup>
                    <PackageVersion Include="Aspire.Hosting.Redis" Version="$(AspireVersion)" />
                    <PackageVersion Include="Aspire.StackExchange.Redis" Version="$(AspireUnstableVersion)" />
                </ItemGroup>
            </Project>
            """);
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, config =>
        {
            config.DotNetCliRunnerFactory = (sp) =>
            {
                return new TestDotNetCliRunner()
                {
                    SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _, _) =>
                    {
                        var packages = new List<NuGetPackageCli>();
 
                        packages.Add(query switch
                        {
                            "Aspire.AppHost.Sdk" => new NuGetPackageCli { Id = "Aspire.AppHost.Sdk", Version = "9.5.0", Source = "nuget.org" },
                            "Aspire.Hosting.Redis" => new NuGetPackageCli { Id = "Aspire.Hosting.Redis", Version = "9.5.0", Source = "nuget.org" },
                            "Aspire.StackExchange.Redis" => new NuGetPackageCli { Id = "Aspire.StackExchange.Redis", Version = "9.5.0-preview.1", Source = "nuget.org" },
                            _ => throw new InvalidOperationException($"Unexpected package query: {query}"),
                        });
 
                        return (0, packages.ToArray());
                    },
                    GetProjectItemsAndPropertiesAsyncCallback = (projectFile, items, properties, options, cancellationToken) =>
                    {
                        var itemsAndProperties = new JsonObject();
 
                        // For SDK version queries
                        if (properties.Contains("AspireHostingSDKVersion"))
                        {
                            itemsAndProperties.WithSdkVersion("9.4.1");
                            itemsAndProperties.WithPackageReferenceWithoutVersion("Aspire.Hosting.Redis");
                            itemsAndProperties.WithPackageReferenceWithoutVersion("Aspire.StackExchange.Redis");
                        }
 
                        // For property resolution queries
                        if (properties.Contains("AspireVersion"))
                        {
                            itemsAndProperties.WithProperty("AspireVersion", "9.4.1");
                        }
 
                        if (properties.Contains("AspireUnstableVersion"))
                        {
                            itemsAndProperties.WithProperty("AspireUnstableVersion", "9.4.1-preview.1");
                        }
 
                        var json = itemsAndProperties.ToJsonString();
                        var document = JsonDocument.Parse(json);
                        return (0, document);
                    }
                };
            };
 
            config.InteractionServiceFactory = (sp) =>
            {
                var interactionService = new TestInteractionService();
                interactionService.ConfirmCallback = (promptText, defaultValue) =>
                {
                    return true;
                };
 
                return interactionService;
            };
        });
        using var provider = services.BuildServiceProvider();
 
        var logger = provider.GetRequiredService<ILogger<ProjectUpdater>>();
        var runner = provider.GetRequiredService<IDotNetCliRunner>();
        var interactionService = provider.GetRequiredService<IInteractionService>();
        var cache = provider.GetRequiredService<IMemoryCache>();
        var executionContext = CreateExecutionContext(workspace.WorkspaceRoot);
        var packagingService = provider.GetRequiredService<IPackagingService>();
 
        var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
        var selectedChannel = channels.Single(c => c.Name == "default");
 
        var projectUpdater = provider.GetRequiredService<IProjectUpdater>();
        var updateResult = await projectUpdater.UpdateProjectAsync(CreateUpdateContext(appHostProjectFile, selectedChannel)).DefaultTimeout();
 
        Assert.True(updateResult.UpdatedApplied);
 
        // Verify Directory.Packages.props was updated with new versions
        var content = await File.ReadAllTextAsync(directoryPackagesPropsFile.FullName);
        Assert.Contains("<PackageVersion Include=\"Aspire.Hosting.Redis\" Version=\"9.5.0\" />", content);
        Assert.Contains("<PackageVersion Include=\"Aspire.StackExchange.Redis\" Version=\"9.5.0-preview.1\" />", content);
    }
 
    [Fact]
    public async Task UpdateProjectFileAsync_CentralPackageManagement_PropertyResolutionFailsWithInvalidSemanticVersion()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var appHostFolder = workspace.CreateDirectory("UpdateTester.AppHost");
        var appHostProjectFile = new FileInfo(Path.Combine(appHostFolder.FullName, "UpdateTester.AppHost.csproj"));
 
        var directoryPackagesPropsFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "Directory.Packages.props"));
 
        // Create AppHost project file
        await File.WriteAllTextAsync(
            appHostProjectFile.FullName,
            $$"""
            <Project Sdk="Microsoft.NET.Sdk">
                <Sdk Name="Aspire.AppHost.Sdk" Version="9.5.1" />
                <ItemGroup>
                    <PackageReference Include="Aspire.Hosting.Redis" />
                </ItemGroup>
            </Project>
            """);
 
        // Create Directory.Packages.props with MSBuild property expression that resolves to an invalid version
        await File.WriteAllTextAsync(
            directoryPackagesPropsFile.FullName,
            $$"""
            <Project>
                <PropertyGroup>
                    <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
                    <InvalidVersionProperty>not-a-version</InvalidVersionProperty>
                </PropertyGroup>
                <ItemGroup>
                    <PackageVersion Include="Aspire.Hosting.Redis" Version="$(InvalidVersionProperty)" />
                </ItemGroup>
            </Project>
            """);
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, config =>
        {
            config.DotNetCliRunnerFactory = (sp) =>
            {
                return new TestDotNetCliRunner()
                {
                    SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _, _) =>
                    {
                        var packages = new List<NuGetPackageCli>();
 
                        packages.Add(query switch
                        {
                            "Aspire.AppHost.Sdk" => new NuGetPackageCli { Id = "Aspire.AppHost.Sdk", Version = "9.5.0", Source = "nuget.org" },
                            "Aspire.Hosting.Redis" => new NuGetPackageCli { Id = "Aspire.Hosting.Redis", Version = "9.5.0", Source = "nuget.org" },
                            _ => throw new InvalidOperationException($"Unexpected package query: {query}"),
                        });
 
                        return (0, packages.ToArray());
                    },
                    GetProjectItemsAndPropertiesAsyncCallback = (projectFile, items, properties, options, cancellationToken) =>
                    {
                        var itemsAndProperties = new JsonObject();
 
                        // For SDK version queries
                        if (properties.Contains("AspireHostingSDKVersion"))
                        {
                            itemsAndProperties.WithSdkVersion("9.4.1");
                            itemsAndProperties.WithPackageReferenceWithoutVersion("Aspire.Hosting.Redis");
                        }
 
                        // For property resolution queries - return invalid semantic version
                        if (properties.Contains("InvalidVersionProperty"))
                        {
                            itemsAndProperties.WithProperty("InvalidVersionProperty", "not-a-version");
                        }
 
                        var json = itemsAndProperties.ToJsonString();
                        var document = JsonDocument.Parse(json);
                        return (0, document);
                    }
                };
            };
 
            config.InteractionServiceFactory = (sp) =>
            {
                var interactionService = new TestInteractionService();
                interactionService.ConfirmCallback = (promptText, defaultValue) =>
                {
                    return true;
                };
 
                return interactionService;
            };
        });
        using var provider = services.BuildServiceProvider();
 
        var logger = provider.GetRequiredService<ILogger<ProjectUpdater>>();
        var runner = provider.GetRequiredService<IDotNetCliRunner>();
        var interactionService = provider.GetRequiredService<IInteractionService>();
        var cache = provider.GetRequiredService<IMemoryCache>();
        var executionContext = CreateExecutionContext(workspace.WorkspaceRoot);
        var packagingService = provider.GetRequiredService<IPackagingService>();
 
        var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
        var selectedChannel = channels.Single(c => c.Name == "default");
 
        var projectUpdater = provider.GetRequiredService<IProjectUpdater>();
 
        // This should throw a ProjectUpdaterException
        var exception = await Assert.ThrowsAsync<ProjectUpdaterException>(async () =>
        {
            await projectUpdater.UpdateProjectAsync(CreateUpdateContext(appHostProjectFile, selectedChannel)).DefaultTimeout();
        });
 
        Assert.Contains("Unable to resolve MSBuild property 'InvalidVersionProperty' to a valid semantic version", exception.Message);
    }
 
    [Fact]
    public async Task UpdateProjectFileAsync_CentralPackageManagement_PropertyResolutionFailsWithUnresolvableProperty()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var appHostFolder = workspace.CreateDirectory("UpdateTester.AppHost");
        var appHostProjectFile = new FileInfo(Path.Combine(appHostFolder.FullName, "UpdateTester.AppHost.csproj"));
 
        var directoryPackagesPropsFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "Directory.Packages.props"));
 
        // Create AppHost project file
        await File.WriteAllTextAsync(
            appHostProjectFile.FullName,
            $$"""
            <Project Sdk="Microsoft.NET.Sdk">
                <Sdk Name="Aspire.AppHost.Sdk" Version="9.5.1" />
                <ItemGroup>
                    <PackageReference Include="Aspire.Hosting.Redis" />
                </ItemGroup>
            </Project>
            """);
 
        // Create Directory.Packages.props with MSBuild property expression that cannot be resolved
        await File.WriteAllTextAsync(
            directoryPackagesPropsFile.FullName,
            $$"""
            <Project>
                <PropertyGroup>
                    <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
                </PropertyGroup>
                <ItemGroup>
                    <PackageVersion Include="Aspire.Hosting.Redis" Version="$(NonExistentProperty)" />
                </ItemGroup>
            </Project>
            """);
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, config =>
        {
            config.DotNetCliRunnerFactory = (sp) =>
            {
                return new TestDotNetCliRunner()
                {
                    SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _, _) =>
                    {
                        var packages = new List<NuGetPackageCli>();
 
                        packages.Add(query switch
                        {
                            "Aspire.AppHost.Sdk" => new NuGetPackageCli { Id = "Aspire.AppHost.Sdk", Version = "9.5.0", Source = "nuget.org" },
                            "Aspire.Hosting.Redis" => new NuGetPackageCli { Id = "Aspire.Hosting.Redis", Version = "9.5.0", Source = "nuget.org" },
                            _ => throw new InvalidOperationException($"Unexpected package query: {query}"),
                        });
 
                        return (0, packages.ToArray());
                    },
                    GetProjectItemsAndPropertiesAsyncCallback = (projectFile, items, properties, options, cancellationToken) =>
                    {
                        var itemsAndProperties = new JsonObject();
 
                        // For SDK version queries
                        if (properties.Contains("AspireHostingSDKVersion"))
                        {
                            itemsAndProperties.WithSdkVersion("9.4.1");
                            itemsAndProperties.WithPackageReferenceWithoutVersion("Aspire.Hosting.Redis");
                        }
 
                        // For property resolution queries - don't include the property, simulating it doesn't exist
                        // This will result in the property not being in the response, which should be handled gracefully
 
                        var json = itemsAndProperties.ToJsonString();
                        var document = JsonDocument.Parse(json);
                        return (0, document);
                    }
                };
            };
 
            config.InteractionServiceFactory = (sp) =>
            {
                var interactionService = new TestInteractionService();
                interactionService.ConfirmCallback = (promptText, defaultValue) =>
                {
                    return true;
                };
 
                return interactionService;
            };
        });
        using var provider = services.BuildServiceProvider();
 
        var logger = provider.GetRequiredService<ILogger<ProjectUpdater>>();
        var runner = provider.GetRequiredService<IDotNetCliRunner>();
        var interactionService = provider.GetRequiredService<IInteractionService>();
        var cache = provider.GetRequiredService<IMemoryCache>();
        var executionContext = CreateExecutionContext(workspace.WorkspaceRoot);
        var packagingService = provider.GetRequiredService<IPackagingService>();
 
        var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
        var selectedChannel = channels.Single(c => c.Name == "default");
 
        var projectUpdater = provider.GetRequiredService<IProjectUpdater>();
 
        // This should throw a ProjectUpdaterException
        var exception = await Assert.ThrowsAsync<ProjectUpdaterException>(async () =>
        {
            await projectUpdater.UpdateProjectAsync(CreateUpdateContext(appHostProjectFile, selectedChannel)).DefaultTimeout();
        });
 
        Assert.Contains("Unable to resolve MSBuild property 'NonExistentProperty' to a valid semantic version", exception.Message);
        Assert.Contains("Resolved value: 'null'", exception.Message);
    }
 
    [Fact]
    public async Task UpdateProject_FallbackMode_WhenSdkUnresolvable()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var appHostFolder = workspace.CreateDirectory("UpdateTester.AppHost");
        var appHostProjectFile = new FileInfo(Path.Combine(appHostFolder.FullName, "UpdateTester.AppHost.csproj"));
 
        // Create AppHost project file with an unresolvable SDK version
        await File.WriteAllTextAsync(
            appHostProjectFile.FullName,
            """
            <Project Sdk="Microsoft.NET.Sdk">
                <Sdk Name="Aspire.AppHost.Sdk" Version="99.0.0-unresolvable" />
                <ItemGroup>
                    <PackageReference Include="Aspire.Hosting.AppHost" Version="99.0.0-unresolvable" />
                    <PackageReference Include="Aspire.Hosting.Redis" Version="99.0.0-unresolvable" />
                </ItemGroup>
            </Project>
            """);
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, config =>
        {
            config.DotNetCliRunnerFactory = (sp) =>
            {
                return new TestDotNetCliRunner()
                {
                    SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _, _) =>
                    {
                        var packages = new List<NuGetPackageCli>();
 
                        packages.Add(query switch
                        {
                            "Aspire.AppHost.Sdk" => new NuGetPackageCli { Id = "Aspire.AppHost.Sdk", Version = "9.5.0", Source = "nuget.org" },
                            "Aspire.Hosting.AppHost" => new NuGetPackageCli { Id = "Aspire.Hosting.AppHost", Version = "9.5.0", Source = "nuget.org" },
                            "Aspire.Hosting.Redis" => new NuGetPackageCli { Id = "Aspire.Hosting.Redis", Version = "9.5.0", Source = "nuget.org" },
                            _ => throw new InvalidOperationException($"Unexpected package query: {query}"),
                        });
 
                        return (0, packages.ToArray());
                    },
 
                    GetProjectItemsAndPropertiesAsyncCallback = (projectFile, _, _, _, _) =>
                    {
                        if (projectFile.FullName == appHostProjectFile.FullName)
                        {
                            // Simulate MSBuild failure due to unresolvable SDK
                            return (1, null);
                        }
                        else
                        {
                            throw new InvalidOperationException("Unexpected project file.");
                        }
                    },
 
                    AddPackageAsyncCallback = (projectFile, packageName, version, source, noRestore, options, cancellationToken) =>
                    {
                        // Simulate successful package addition
                        return 0;
                    }
                };
            };
 
            config.InteractionServiceFactory = (sp) =>
            {
                var interactionService = new TestInteractionService();
                interactionService.ConfirmCallback = (promptText, defaultValue) =>
                {
                    return true;
                };
 
                return interactionService;
            };
        });
        using var provider = services.BuildServiceProvider();
 
        var logger = provider.GetRequiredService<ILogger<ProjectUpdater>>();
        var runner = provider.GetRequiredService<IDotNetCliRunner>();
        var interactionService = provider.GetRequiredService<IInteractionService>();
        var cache = provider.GetRequiredService<IMemoryCache>();
        var executionContext = CreateExecutionContext(workspace.WorkspaceRoot);
        var fallbackParser = provider.GetRequiredService<FallbackProjectParser>();
        var packagingService = provider.GetRequiredService<IPackagingService>();
 
        var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
        var selectedChannel = channels.Single(c => c.Name == "default");
 
        var projectUpdater = new ProjectUpdater(logger, runner, interactionService, cache, executionContext, fallbackParser);
        var updateResult = await projectUpdater.UpdateProjectAsync(CreateUpdateContext(appHostProjectFile, selectedChannel)).DefaultTimeout();
 
        // Should not throw ProjectUpdaterException; should produce update steps including AppHost SDK
        Assert.True(updateResult.UpdatedApplied);
    }
 
    [Fact]
    public async Task FallbackMode_PackageReferenceWithoutVersion_CPM()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var appHostFolder = workspace.CreateDirectory("UpdateTester.AppHost");
        var appHostProjectFile = new FileInfo(Path.Combine(appHostFolder.FullName, "UpdateTester.AppHost.csproj"));
 
        var directoryPackagesPropsFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "Directory.Packages.props"));
 
        // Create AppHost project file with package reference without version (CPM)
        await File.WriteAllTextAsync(
            appHostProjectFile.FullName,
            """
            <Project Sdk="Microsoft.NET.Sdk">
                <Sdk Name="Aspire.AppHost.Sdk" Version="99.0.0-unresolvable" />
                <ItemGroup>
                    <PackageReference Include="Aspire.Hosting.Redis" />
                </ItemGroup>
            </Project>
            """);
 
        // Create Directory.Packages.props
        await File.WriteAllTextAsync(
            directoryPackagesPropsFile.FullName,
            """
            <Project>
                <PropertyGroup>
                    <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
                </PropertyGroup>
                <ItemGroup>
                    <PackageVersion Include="Aspire.Hosting.Redis" Version="9.4.1" />
                </ItemGroup>
            </Project>
            """);
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, config =>
        {
            config.DotNetCliRunnerFactory = (sp) =>
            {
                return new TestDotNetCliRunner()
                {
                    SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _, _) =>
                    {
                        var packages = new List<NuGetPackageCli>();
 
                        packages.Add(query switch
                        {
                            "Aspire.AppHost.Sdk" => new NuGetPackageCli { Id = "Aspire.AppHost.Sdk", Version = "9.5.0", Source = "nuget.org" },
                            "Aspire.Hosting.Redis" => new NuGetPackageCli { Id = "Aspire.Hosting.Redis", Version = "9.5.0", Source = "nuget.org" },
                            _ => throw new InvalidOperationException($"Unexpected package query: {query}"),
                        });
 
                        return (0, packages.ToArray());
                    },
 
                    GetProjectItemsAndPropertiesAsyncCallback = (projectFile, _, _, _, _) =>
                    {
                        if (projectFile.FullName == appHostProjectFile.FullName)
                        {
                            // Simulate MSBuild failure due to unresolvable SDK
                            return (1, null);
                        }
                        else
                        {
                            throw new InvalidOperationException("Unexpected project file.");
                        }
                    }
                };
            };
 
            config.InteractionServiceFactory = (sp) =>
            {
                var interactionService = new TestInteractionService();
                interactionService.ConfirmCallback = (promptText, defaultValue) =>
                {
                    return true;
                };
 
                return interactionService;
            };
        });
        using var provider = services.BuildServiceProvider();
 
        var logger = provider.GetRequiredService<ILogger<ProjectUpdater>>();
        var runner = provider.GetRequiredService<IDotNetCliRunner>();
        var interactionService = provider.GetRequiredService<IInteractionService>();
        var cache = provider.GetRequiredService<IMemoryCache>();
        var executionContext = CreateExecutionContext(workspace.WorkspaceRoot);
        var fallbackParser = provider.GetRequiredService<FallbackProjectParser>();
        var packagingService = provider.GetRequiredService<IPackagingService>();
 
        var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
        var selectedChannel = channels.Single(c => c.Name == "default");
 
        var projectUpdater = new ProjectUpdater(logger, runner, interactionService, cache, executionContext, fallbackParser);
        var updateResult = await projectUpdater.UpdateProjectAsync(CreateUpdateContext(appHostProjectFile, selectedChannel)).DefaultTimeout();
 
        // Should discover package reference (version may be absent) and not crash
        Assert.True(updateResult.UpdatedApplied);
    }
 
    [Fact]
    public async Task FallbackMode_InvalidXml_StillFails()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var appHostFolder = workspace.CreateDirectory("UpdateTester.AppHost");
        var appHostProjectFile = new FileInfo(Path.Combine(appHostFolder.FullName, "UpdateTester.AppHost.csproj"));
 
        // Create malformed AppHost project file
        await File.WriteAllTextAsync(
            appHostProjectFile.FullName,
            """
            <Project Sdk="Microsoft.NET.Sdk">
                <Sdk Name="Aspire.AppHost.Sdk" Version="99.0.0-unresolvable" />
                <!-- Missing closing tag to make invalid XML -->
                <ItemGroup>
                    <PackageReference Include="Aspire.Hosting.AppHost" Version="99.0.0-unresolvable" />
            """);
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, config =>
        {
            config.DotNetCliRunnerFactory = (sp) =>
            {
                return new TestDotNetCliRunner()
                {
                    SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _, _) =>
                    {
                        var packages = new List<NuGetPackageCli>();
 
                        packages.Add(query switch
                        {
                            "Aspire.AppHost.Sdk" => new NuGetPackageCli { Id = "Aspire.AppHost.Sdk", Version = "9.5.0", Source = "nuget.org" },
                            _ => throw new InvalidOperationException($"Unexpected package query: {query}"),
                        });
 
                        return (0, packages.ToArray());
                    },
 
                    GetProjectItemsAndPropertiesAsyncCallback = (projectFile, _, _, _, _) =>
                    {
                        if (projectFile.FullName == appHostProjectFile.FullName)
                        {
                            // Simulate MSBuild failure due to unresolvable SDK
                            return (1, null);
                        }
                        else
                        {
                            throw new InvalidOperationException("Unexpected project file.");
                        }
                    }
                };
            };
 
            config.InteractionServiceFactory = (sp) =>
            {
                var interactionService = new TestInteractionService();
                return interactionService;
            };
        });
        using var provider = services.BuildServiceProvider();
 
        var logger = provider.GetRequiredService<ILogger<ProjectUpdater>>();
        var runner = provider.GetRequiredService<IDotNetCliRunner>();
        var interactionService = provider.GetRequiredService<IInteractionService>();
        var cache = provider.GetRequiredService<IMemoryCache>();
        var executionContext = CreateExecutionContext(workspace.WorkspaceRoot);
        var fallbackParser = provider.GetRequiredService<FallbackProjectParser>();
        var packagingService = provider.GetRequiredService<IPackagingService>();
 
        var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
        var selectedChannel = channels.Single(c => c.Name == "default");
 
        var projectUpdater = new ProjectUpdater(logger, runner, interactionService, cache, executionContext, fallbackParser);
 
        // Should throw ProjectUpdaterException due to invalid XML
        await Assert.ThrowsAsync<ProjectUpdaterException>(() =>
            projectUpdater.UpdateProjectAsync(CreateUpdateContext(appHostProjectFile, selectedChannel)).DefaultTimeout());
    }
 
    [Fact]
    public async Task NormalMode_NoFallback()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var appHostFolder = workspace.CreateDirectory("UpdateTester.AppHost");
        var appHostProjectFile = new FileInfo(Path.Combine(appHostFolder.FullName, "UpdateTester.AppHost.csproj"));
 
        await File.WriteAllTextAsync(
            appHostProjectFile.FullName,
            $$"""
            <Project Sdk="Microsoft.NET.Sdk">
                <Sdk Name="Aspire.AppHost.Sdk" Version="9.4.1" />
            </Project>
            """);
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, config =>
        {
            config.DotNetCliRunnerFactory = (sp) =>
            {
                return new TestDotNetCliRunner()
                {
                    SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _, _) =>
                    {
                        var packages = new List<NuGetPackageCli>();
 
                        packages.Add(query switch
                        {
                            "Aspire.AppHost.Sdk" => new NuGetPackageCli { Id = "Aspire.AppHost.Sdk", Version = "9.4.1", Source = "nuget.org" },
                            _ => throw new InvalidOperationException("Unexpected package query."),
                        });
 
                        return (0, packages.ToArray());
                    },
 
                    GetProjectItemsAndPropertiesAsyncCallback = (projectFile, _, _, _, _) =>
                    {
                        // Normal successful MSBuild evaluation
                        var itemsAndProperties = new JsonObject();
 
                        if (projectFile.FullName == appHostProjectFile.FullName)
                        {
                            itemsAndProperties.WithSdkVersion("9.4.1");
                        }
                        else
                        {
                            throw new InvalidOperationException("Unexpected project file.");
                        }
 
                        var json = itemsAndProperties.ToJsonString();
                        var document = JsonDocument.Parse(json);
                        return (0, document);
                    }
                };
            };
 
            config.InteractionServiceFactory = (sp) =>
            {
                var interactionService = new TestInteractionService();
                return interactionService;
            };
        });
        using var provider = services.BuildServiceProvider();
 
        var logger = provider.GetRequiredService<ILogger<ProjectUpdater>>();
        var runner = provider.GetRequiredService<IDotNetCliRunner>();
        var interactionService = provider.GetRequiredService<IInteractionService>();
        var cache = provider.GetRequiredService<IMemoryCache>();
        var executionContext = CreateExecutionContext(workspace.WorkspaceRoot);
        var fallbackParser = provider.GetRequiredService<FallbackProjectParser>();
        var packagingService = provider.GetRequiredService<IPackagingService>();
 
        var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
        var selectedChannel = channels.Single(c => c.Name == "default");
 
        var projectUpdater = new ProjectUpdater(logger, runner, interactionService, cache, executionContext, fallbackParser);
        var updateResult = await projectUpdater.UpdateProjectAsync(CreateUpdateContext(appHostProjectFile, selectedChannel)).DefaultTimeout();
 
        // Normal path unaffected - no updates needed since version is already current
        Assert.False(updateResult.UpdatedApplied);
    }
 
    [Fact]
    public async Task UpdateProjectFileAsync_SingleFileAppHost_UpdatesSdkDirectiveWithVersion()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var appHostFolder = workspace.CreateDirectory("UpdateTester.AppHost");
        var appHostFile = new FileInfo(Path.Combine(appHostFolder.FullName, "apphost.cs"));
 
        // Create a single-file app host with an explicit version using @ separator
        await File.WriteAllTextAsync(
            appHostFile.FullName,
            """
            #:sdk Aspire.AppHost.Sdk@9.4.1
            using Aspire.Hosting;
            var builder = DistributedApplication.CreateBuilder(args);
            builder.Build().Run();
            """);
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, config =>
        {
            config.DotNetCliRunnerFactory = (sp) =>
            {
                return new TestDotNetCliRunner()
                {
                    SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _, _) =>
                    {
                        var packages = new List<NuGetPackageCli>();
 
                        packages.Add(query switch
                        {
                            "Aspire.AppHost.Sdk" => new NuGetPackageCli { Id = "Aspire.AppHost.Sdk", Version = "9.5.0", Source = "nuget.org" },
                            _ => throw new InvalidOperationException($"Unexpected package query: {query}"),
                        });
 
                        return (0, packages.ToArray());
                    },
                    GetProjectItemsAndPropertiesAsyncCallback = (projectFile, items, properties, options, cancellationToken) =>
                    {
                        var itemsAndProperties = new JsonObject();
                        itemsAndProperties.WithSdkVersion("9.4.1");
 
                        var json = itemsAndProperties.ToJsonString();
                        var document = JsonDocument.Parse(json);
                        return (0, document);
                    }
                };
            };
 
            config.InteractionServiceFactory = (sp) =>
            {
                var interactionService = new TestInteractionService();
                interactionService.ConfirmCallback = (promptText, defaultValue) =>
                {
                    return true;
                };
 
                return interactionService;
            };
        });
        using var provider = services.BuildServiceProvider();
 
        var logger = provider.GetRequiredService<ILogger<ProjectUpdater>>();
        var runner = provider.GetRequiredService<IDotNetCliRunner>();
        var interactionService = provider.GetRequiredService<IInteractionService>();
        var cache = provider.GetRequiredService<IMemoryCache>();
        var executionContext = CreateExecutionContext(workspace.WorkspaceRoot);
        var fallbackParser = provider.GetRequiredService<FallbackProjectParser>();
        var packagingService = provider.GetRequiredService<IPackagingService>();
 
        var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
        var selectedChannel = channels.Single(c => c.Name == "default");
 
        var projectUpdater = new ProjectUpdater(logger, runner, interactionService, cache, executionContext, fallbackParser);
        var updateResult = await projectUpdater.UpdateProjectAsync(CreateUpdateContext(appHostFile, selectedChannel)).DefaultTimeout();
 
        Assert.True(updateResult.UpdatedApplied);
 
        // Verify the SDK directive was updated from old to new version
        var updatedContent = await File.ReadAllTextAsync(appHostFile.FullName);
        Assert.Contains("#:sdk Aspire.AppHost.Sdk@9.5.0", updatedContent);
        Assert.DoesNotContain("9.4.1", updatedContent);
    }
 
    [Fact]
    public async Task UpdateProjectFileAsync_SingleFileAppHost_RemovesLegacyAppHostPackageDirectiveDuringSdkBump()
    {
        // Companion to the .csproj path covered by
        // UpdateProjectFileAsync_StaleAppHostPackageReference_RemovedEvenWhenSdkAlreadyCurrent.
        // Aspire.AppHost.Sdk pulls Aspire.Hosting.AppHost in implicitly, so an
        // explicit `#:package Aspire.Hosting.AppHost@<version>` directive in a
        // single-file AppHost is redundant. After a channel switch rewrites
        // nuget.config to pin Aspire* to the channel feed, the deferred restore
        // would otherwise hit NU1103 because the pinned channel does not carry
        // the user's stable version.
        // See https://github.com/dotnet/aspire/issues/15891.
 
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var appHostFolder = workspace.CreateDirectory("UpdateTester.AppHost");
        var appHostFile = new FileInfo(Path.Combine(appHostFolder.FullName, "apphost.cs"));
 
        await File.WriteAllTextAsync(
            appHostFile.FullName,
            """
            #:sdk Aspire.AppHost.Sdk@9.4.1
            #:package Aspire.Hosting.AppHost@9.4.1
            #:package Aspire.Hosting.Redis@9.4.1
            using Aspire.Hosting;
            var builder = DistributedApplication.CreateBuilder(args);
            builder.AddRedis("redis");
            builder.Build().Run();
            """);
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, config =>
        {
            config.DotNetCliRunnerFactory = (sp) =>
            {
                return new TestDotNetCliRunner()
                {
                    SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _, _) =>
                    {
                        var packages = new List<NuGetPackageCli>
                        {
                            query switch
                            {
                                "Aspire.AppHost.Sdk" => new NuGetPackageCli { Id = "Aspire.AppHost.Sdk", Version = "9.5.0", Source = "nuget.org" },
                                "Aspire.Hosting.Redis" => new NuGetPackageCli { Id = "Aspire.Hosting.Redis", Version = "9.5.0", Source = "nuget.org" },
                                _ => throw new InvalidOperationException($"Unexpected package query: {query}"),
                            }
                        };
                        return (0, packages.ToArray());
                    },
                    GetProjectItemsAndPropertiesAsyncCallback = (projectFile, _, _, _, _) =>
                    {
                        var itemsAndProperties = new JsonObject();
                        itemsAndProperties.WithSdkVersion("9.4.1");
                        // Surface the explicit Aspire.Hosting.AppHost / Aspire.Hosting.Redis
                        // directives the way MSBuild would for an apphost.cs file.
                        var items = new JsonObject
                        {
                            ["PackageReference"] = new JsonArray
                            {
                                new JsonObject { ["Identity"] = "Aspire.Hosting.AppHost", ["Version"] = "9.4.1" },
                                new JsonObject { ["Identity"] = "Aspire.Hosting.Redis", ["Version"] = "9.4.1" },
                            },
                        };
                        itemsAndProperties["Items"] = items;
                        var json = itemsAndProperties.ToJsonString();
                        var document = JsonDocument.Parse(json);
                        return (0, document);
                    },
                    AddPackageAsyncCallback = (_, _, _, _, _, _, _) => 0,
                };
            };
 
            config.InteractionServiceFactory = (sp) =>
            {
                var interactionService = new TestInteractionService();
                interactionService.ConfirmCallback = (_, _) => true;
                return interactionService;
            };
        });
        using var provider = services.BuildServiceProvider();
 
        var packagingService = provider.GetRequiredService<IPackagingService>();
        var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
        var selectedChannel = channels.Single(c => c.Name == "default");
 
        var projectUpdater = provider.GetRequiredService<IProjectUpdater>();
        var updateResult = await projectUpdater.UpdateProjectAsync(CreateUpdateContext(appHostFile, selectedChannel)).DefaultTimeout();
 
        Assert.True(updateResult.UpdatedApplied);
 
        var updatedContent = await File.ReadAllTextAsync(appHostFile.FullName);
        Assert.Contains("#:sdk Aspire.AppHost.Sdk@9.5.0", updatedContent);
        Assert.DoesNotContain("Aspire.Hosting.AppHost", updatedContent);
        // Aspire.Hosting.Redis directive is preserved (and bumped via dotnet package add,
        // which is mocked here, so the source text still shows 9.4.1; the important
        // assertion is that the directive line itself was not removed).
        Assert.Contains("#:package Aspire.Hosting.Redis@", updatedContent);
    }
 
    [Fact]
    public async Task UpdateProjectFileAsync_SingleFileAppHost_RemovesLegacyAppHostPackageDirectiveWhenSdkAlreadyCurrent()
    {
        // SDK is already on the latest version, so AnalyzeAppHostSdkAsync
        // early-returns. The cleanup step path must still strip the redundant
        // `#:package Aspire.Hosting.AppHost@<version>` directive so a re-run of
        // `aspire update` after a partial migration recovers cleanly.
 
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var appHostFolder = workspace.CreateDirectory("UpdateTester.AppHost");
        var appHostFile = new FileInfo(Path.Combine(appHostFolder.FullName, "apphost.cs"));
 
        await File.WriteAllTextAsync(
            appHostFile.FullName,
            """
            #:sdk Aspire.AppHost.Sdk@9.5.0
            #:package Aspire.Hosting.AppHost@9.5.0
            using Aspire.Hosting;
            var builder = DistributedApplication.CreateBuilder(args);
            builder.Build().Run();
            """);
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, config =>
        {
            config.DotNetCliRunnerFactory = (sp) =>
            {
                return new TestDotNetCliRunner()
                {
                    SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _, _) =>
                    {
                        var packages = new List<NuGetPackageCli>
                        {
                            query switch
                            {
                                "Aspire.AppHost.Sdk" => new NuGetPackageCli { Id = "Aspire.AppHost.Sdk", Version = "9.5.0", Source = "nuget.org" },
                                _ => throw new InvalidOperationException($"Unexpected package query: {query}"),
                            }
                        };
                        return (0, packages.ToArray());
                    },
                    GetProjectItemsAndPropertiesAsyncCallback = (projectFile, _, _, _, _) =>
                    {
                        var itemsAndProperties = new JsonObject();
                        itemsAndProperties.WithSdkVersion("9.5.0");
                        var items = new JsonObject
                        {
                            ["PackageReference"] = new JsonArray
                            {
                                new JsonObject { ["Identity"] = "Aspire.Hosting.AppHost", ["Version"] = "9.5.0" },
                            },
                        };
                        itemsAndProperties["Items"] = items;
                        var json = itemsAndProperties.ToJsonString();
                        var document = JsonDocument.Parse(json);
                        return (0, document);
                    }
                };
            };
 
            config.InteractionServiceFactory = (sp) =>
            {
                var interactionService = new TestInteractionService();
                interactionService.ConfirmCallback = (_, _) => true;
                return interactionService;
            };
        });
        using var provider = services.BuildServiceProvider();
 
        var packagingService = provider.GetRequiredService<IPackagingService>();
        var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
        var selectedChannel = channels.Single(c => c.Name == "default");
 
        var projectUpdater = provider.GetRequiredService<IProjectUpdater>();
        await projectUpdater.UpdateProjectAsync(CreateUpdateContext(appHostFile, selectedChannel)).DefaultTimeout();
 
        var updatedContent = await File.ReadAllTextAsync(appHostFile.FullName);
        Assert.DoesNotContain("Aspire.Hosting.AppHost", updatedContent);
        Assert.Contains("#:sdk Aspire.AppHost.Sdk@9.5.0", updatedContent);
    }
 
    [Fact]
    public async Task UpdateProjectFileAsync_SingleFileAppHost_UpdatesSdkDirectiveWithWildcard()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var appHostFolder = workspace.CreateDirectory("UpdateTester.AppHost");
        var appHostFile = new FileInfo(Path.Combine(appHostFolder.FullName, "apphost.cs"));
 
        // Create a single-file app host with wildcard version
        await File.WriteAllTextAsync(
            appHostFile.FullName,
            """
            #:sdk Aspire.AppHost.Sdk@*
            using Aspire.Hosting;
            var builder = DistributedApplication.CreateBuilder(args);
            builder.Build().Run();
            """);
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, config =>
        {
            config.DotNetCliRunnerFactory = (sp) =>
            {
                return new TestDotNetCliRunner()
                {
                    SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _, _) =>
                    {
                        var packages = new List<NuGetPackageCli>();
 
                        packages.Add(query switch
                        {
                            "Aspire.AppHost.Sdk" => new NuGetPackageCli { Id = "Aspire.AppHost.Sdk", Version = "9.5.0", Source = "nuget.org" },
                            _ => throw new InvalidOperationException($"Unexpected package query: {query}"),
                        });
 
                        return (0, packages.ToArray());
                    },
                    GetProjectItemsAndPropertiesAsyncCallback = (projectFile, items, properties, options, cancellationToken) =>
                    {
                        var itemsAndProperties = new JsonObject();
                        itemsAndProperties.WithSdkVersion("*");
 
                        var json = itemsAndProperties.ToJsonString();
                        var document = JsonDocument.Parse(json);
                        return (0, document);
                    }
                };
            };
 
            config.InteractionServiceFactory = (sp) =>
            {
                var interactionService = new TestInteractionService();
                interactionService.ConfirmCallback = (promptText, defaultValue) =>
                {
                    return true;
                };
 
                return interactionService;
            };
        });
        using var provider = services.BuildServiceProvider();
 
        var logger = provider.GetRequiredService<ILogger<ProjectUpdater>>();
        var runner = provider.GetRequiredService<IDotNetCliRunner>();
        var interactionService = provider.GetRequiredService<IInteractionService>();
        var cache = provider.GetRequiredService<IMemoryCache>();
        var executionContext = CreateExecutionContext(workspace.WorkspaceRoot);
        var fallbackParser = provider.GetRequiredService<FallbackProjectParser>();
        var packagingService = provider.GetRequiredService<IPackagingService>();
 
        var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
        var selectedChannel = channels.Single(c => c.Name == "default");
 
        var projectUpdater = new ProjectUpdater(logger, runner, interactionService, cache, executionContext, fallbackParser);
        var updateResult = await projectUpdater.UpdateProjectAsync(CreateUpdateContext(appHostFile, selectedChannel)).DefaultTimeout();
 
        Assert.True(updateResult.UpdatedApplied);
 
        // Verify the SDK directive was updated from wildcard to specific version
        var updatedContent = await File.ReadAllTextAsync(appHostFile.FullName);
        Assert.Contains("#:sdk Aspire.AppHost.Sdk@9.5.0", updatedContent);
    }
 
    [Fact]
    public async Task UpdateProjectFileAsync_PackageReferenceWithWildcard_DoesNotFail()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var appHostFolder = workspace.CreateDirectory("UpdateTester.AppHost");
        var appHostProjectFile = new FileInfo(Path.Combine(appHostFolder.FullName, "UpdateTester.AppHost.csproj"));
 
        await File.WriteAllTextAsync(
            appHostProjectFile.FullName,
            """
            <Project Sdk="Microsoft.NET.Sdk">
                <Sdk Name="Aspire.AppHost.Sdk" Version="9.5.1" />
            </Project>
            """);
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, config =>
        {
            config.DotNetCliRunnerFactory = (sp) =>
            {
                return new TestDotNetCliRunner()
                {
                    SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _, _) =>
                    {
                        var packages = new List<NuGetPackageCli>();
 
                        packages.Add(query switch
                        {
                            "Aspire.AppHost.Sdk" => new NuGetPackageCli { Id = "Aspire.AppHost.Sdk", Version = "9.5.0", Source = "nuget.org" },
                            "Aspire.Hosting.AppHost" => new NuGetPackageCli { Id = "Aspire.Hosting.AppHost", Version = "9.5.0", Source = "nuget.org" },
                            _ => throw new InvalidOperationException($"Unexpected package query: {query}"),
                        });
 
                        return (0, packages.ToArray());
                    },
                    GetProjectItemsAndPropertiesAsyncCallback = (projectFile, items, properties, options, cancellationToken) =>
                    {
                        var itemsAndProperties = new JsonObject();
                        itemsAndProperties.WithSdkVersion("9.4.1");
                        // Add a package reference with wildcard version
                        itemsAndProperties.WithPackageReference("Aspire.Hosting.AppHost", "*");
 
                        var json = itemsAndProperties.ToJsonString();
                        var document = JsonDocument.Parse(json);
                        return (0, document);
                    },
                    AddPackageAsyncCallback = (projectFile, packageName, version, source, noRestore, options, cancellationToken) =>
                    {
                        // Simulate successful package addition
                        return 0;
                    }
                };
            };
 
            config.InteractionServiceFactory = (sp) =>
            {
                var interactionService = new TestInteractionService();
                interactionService.ConfirmCallback = (promptText, defaultValue) =>
                {
                    return true;
                };
 
                return interactionService;
            };
        });
        using var provider = services.BuildServiceProvider();
 
        var logger = provider.GetRequiredService<ILogger<ProjectUpdater>>();
        var runner = provider.GetRequiredService<IDotNetCliRunner>();
        var interactionService = provider.GetRequiredService<IInteractionService>();
        var cache = provider.GetRequiredService<IMemoryCache>();
        var executionContext = CreateExecutionContext(workspace.WorkspaceRoot);
        var fallbackParser = provider.GetRequiredService<FallbackProjectParser>();
        var packagingService = provider.GetRequiredService<IPackagingService>();
 
        var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
        var selectedChannel = channels.Single(c => c.Name == "default");
 
        var projectUpdater = new ProjectUpdater(logger, runner, interactionService, cache, executionContext, fallbackParser);
 
        // This should not throw and should handle the * version gracefully
        var updateResult = await projectUpdater.UpdateProjectAsync(CreateUpdateContext(appHostProjectFile, selectedChannel)).DefaultTimeout();
 
        Assert.True(updateResult.UpdatedApplied);
    }
 
    [Fact]
    public async Task UpdateProjectFileAsync_AppHost_UpdatesSdkAttributeFormat()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var appHostFolder = workspace.CreateDirectory("UpdateTester.AppHost");
        var appHostProjectFile = new FileInfo(Path.Combine(appHostFolder.FullName, "UpdateTester.AppHost.csproj"));
 
        // Create AppHost project with SDK specified via Sdk attribute on Project element (new 13.0 format)
        await File.WriteAllTextAsync(
            appHostProjectFile.FullName,
            """
            <Project Sdk="Aspire.AppHost.Sdk/9.4.1">
              <PropertyGroup>
                <OutputType>Exe</OutputType>
                <TargetFramework>net8.0</TargetFramework>
              </PropertyGroup>
            </Project>
            """);
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, config =>
        {
            config.DotNetCliRunnerFactory = (sp) =>
            {
                return new TestDotNetCliRunner()
                {
                    SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _, _) =>
                    {
                        var packages = new List<NuGetPackageCli>();
 
                        packages.Add(query switch
                        {
                            "Aspire.AppHost.Sdk" => new NuGetPackageCli { Id = "Aspire.AppHost.Sdk", Version = "9.5.0", Source = "nuget.org" },
                            _ => throw new InvalidOperationException($"Unexpected package query: {query}"),
                        });
 
                        return (0, packages.ToArray());
                    },
                    GetProjectItemsAndPropertiesAsyncCallback = (projectFile, items, properties, options, cancellationToken) =>
                    {
                        var itemsAndProperties = new JsonObject();
                        itemsAndProperties.WithSdkVersion("9.4.1");
 
                        var json = itemsAndProperties.ToJsonString();
                        var document = JsonDocument.Parse(json);
                        return (0, document);
                    }
                };
            };
 
            config.InteractionServiceFactory = (sp) =>
            {
                var interactionService = new TestInteractionService();
                interactionService.ConfirmCallback = (promptText, defaultValue) =>
                {
                    return true;
                };
 
                return interactionService;
            };
        });
        using var provider = services.BuildServiceProvider();
 
        var packagingService = provider.GetRequiredService<IPackagingService>();
        var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
        var selectedChannel = channels.Single(c => c.Name == "default");
 
        var projectUpdater = provider.GetRequiredService<IProjectUpdater>();
        var updateResult = await projectUpdater.UpdateProjectAsync(CreateUpdateContext(appHostProjectFile, selectedChannel)).DefaultTimeout();
 
        Assert.True(updateResult.UpdatedApplied);
 
        // Verify the SDK attribute was updated
        var updatedContent = await File.ReadAllTextAsync(appHostProjectFile.FullName);
        Assert.Contains("Aspire.AppHost.Sdk/9.5.0", updatedContent);
        Assert.DoesNotContain("9.4.1", updatedContent);
    }
 
    [Fact]
    public async Task UpdateProjectFileAsync_AppHost_UpdatesSdkElementFormat()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var appHostFolder = workspace.CreateDirectory("UpdateTester.AppHost");
        var appHostProjectFile = new FileInfo(Path.Combine(appHostFolder.FullName, "UpdateTester.AppHost.csproj"));
 
        // Create AppHost project with SDK specified via Sdk element (old 9.5 format)
        await File.WriteAllTextAsync(
            appHostProjectFile.FullName,
            """
            <Project Sdk="Microsoft.NET.Sdk">
              <Sdk Name="Aspire.AppHost.Sdk" Version="9.4.1" />
              <PropertyGroup>
                <OutputType>Exe</OutputType>
                <TargetFramework>net8.0</TargetFramework>
              </PropertyGroup>
            </Project>
            """);
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, config =>
        {
            config.DotNetCliRunnerFactory = (sp) =>
            {
                return new TestDotNetCliRunner()
                {
                    SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _, _) =>
                    {
                        var packages = new List<NuGetPackageCli>();
 
                        packages.Add(query switch
                        {
                            "Aspire.AppHost.Sdk" => new NuGetPackageCli { Id = "Aspire.AppHost.Sdk", Version = "9.5.0", Source = "nuget.org" },
                            _ => throw new InvalidOperationException($"Unexpected package query: {query}"),
                        });
 
                        return (0, packages.ToArray());
                    },
                    GetProjectItemsAndPropertiesAsyncCallback = (projectFile, items, properties, options, cancellationToken) =>
                    {
                        var itemsAndProperties = new JsonObject();
                        itemsAndProperties.WithSdkVersion("9.4.1");
 
                        var json = itemsAndProperties.ToJsonString();
                        var document = JsonDocument.Parse(json);
                        return (0, document);
                    }
                };
            };
 
            config.InteractionServiceFactory = (sp) =>
            {
                var interactionService = new TestInteractionService();
                interactionService.ConfirmCallback = (promptText, defaultValue) =>
                {
                    return true;
                };
 
                return interactionService;
            };
        });
        using var provider = services.BuildServiceProvider();
 
        var packagingService = provider.GetRequiredService<IPackagingService>();
        var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
        var selectedChannel = channels.Single(c => c.Name == "default");
 
        var projectUpdater = provider.GetRequiredService<IProjectUpdater>();
        var updateResult = await projectUpdater.UpdateProjectAsync(CreateUpdateContext(appHostProjectFile, selectedChannel)).DefaultTimeout();
 
        Assert.True(updateResult.UpdatedApplied);
 
        // Verify the project was migrated to the new SDK format
        var updatedContent = await File.ReadAllTextAsync(appHostProjectFile.FullName);
        await Verify(updatedContent, extension: "xml");
    }
 
    [Fact]
    public async Task UpdateProjectFileAsync_TreatsVersionRangeExpressionsLikeWildcard()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var appHostFolder = workspace.CreateDirectory("UpdateTester.AppHost");
        var appHostProjectFile = new FileInfo(Path.Combine(appHostFolder.FullName, "UpdateTester.AppHost.csproj"));
 
        await File.WriteAllTextAsync(
            appHostProjectFile.FullName,
            """
            <Project Sdk="Aspire.AppHost.Sdk/9.4.1">
              <ItemGroup>
                <PackageReference Include="Aspire.Hosting.Azure.Functions" Version="(9.4-*,9.5]" />
                <PackageReference Include="Aspire.Hosting.Redis" Version="9.4.1" />
              </ItemGroup>
            </Project>
            """);
 
        var packagesUpdated = new List<string>();
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, config =>
        {
            config.DotNetCliRunnerFactory = (sp) =>
            {
                return new TestDotNetCliRunner()
                {
                    SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _, _) =>
                    {
                        var packages = new List<NuGetPackageCli>();
 
                        packages.Add(query switch
                        {
                            "Aspire.AppHost.Sdk" => new NuGetPackageCli { Id = "Aspire.AppHost.Sdk", Version = "9.5.0", Source = "nuget.org" },
                            "Aspire.Hosting.Azure.Functions" => new NuGetPackageCli { Id = "Aspire.Hosting.Azure.Functions", Version = "9.5.0", Source = "nuget.org" },
                            "Aspire.Hosting.Redis" => new NuGetPackageCli { Id = "Aspire.Hosting.Redis", Version = "9.5.0", Source = "nuget.org" },
                            _ => throw new InvalidOperationException($"Unexpected package query: {query}"),
                        });
 
                        return (0, packages.ToArray());
                    },
                    GetProjectItemsAndPropertiesAsyncCallback = (projectFile, items, properties, options, cancellationToken) =>
                    {
                        var itemsAndProperties = new JsonObject();
                        itemsAndProperties.WithSdkVersion("9.4.1");
                        // Package with version range expression - should be treated like wildcard and updated
                        itemsAndProperties.WithPackageReference("Aspire.Hosting.Azure.Functions", "(9.4-*,9.5]");
                        // Normal package - should be updated
                        itemsAndProperties.WithPackageReference("Aspire.Hosting.Redis", "9.4.1");
 
                        var json = itemsAndProperties.ToJsonString();
                        var document = JsonDocument.Parse(json);
                        return (0, document);
                    },
                    AddPackageAsyncCallback = (projectFilePath, packageName, packageVersion, nugetSource, noRestore, options, cancellationToken) =>
                    {
                        // Track which packages are updated
                        packagesUpdated.Add(packageName);
                        Assert.Equal("9.5.0", packageVersion);
                        return 0;
                    }
                };
            };
 
            config.InteractionServiceFactory = (sp) =>
            {
                var interactionService = new TestInteractionService();
                interactionService.ConfirmCallback = (promptText, defaultValue) =>
                {
                    return true;
                };
 
                return interactionService;
            };
        });
        using var provider = services.BuildServiceProvider();
 
        var packagingService = provider.GetRequiredService<IPackagingService>();
        var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
        var selectedChannel = channels.Single(c => c.Name == "default");
 
        var projectUpdater = provider.GetRequiredService<IProjectUpdater>();
        var updateResult = await projectUpdater.UpdateProjectAsync(CreateUpdateContext(appHostProjectFile, selectedChannel)).DefaultTimeout();
 
        // Both packages should be updated - range expression is treated like wildcard
        Assert.True(updateResult.UpdatedApplied);
        Assert.Contains("Aspire.Hosting.Azure.Functions", packagesUpdated);
        Assert.Contains("Aspire.Hosting.Redis", packagesUpdated);
    }
 
    [Theory]
    [InlineData("#:sdk Aspire.AppHost.Sdk@9.5.2", true)]
    [InlineData("#:sdk Aspire.AppHost.Sdk@*", true)]
    [InlineData("#:sdk Aspire.AppHost.Sdk@10.0.0-preview.1", true)]
    [InlineData("#:sdk Aspire.AppHost.Sdk", false)]
    [InlineData("#:sdk Aspire.AppHost.Sdk@", false)]
    [InlineData("#:sdk Aspire.AppHost.Sdk 9.5.2", false)]
    [InlineData("#:package Aspire.Hosting.Redis@9.5.2", false)]
    [InlineData("#:package Aspire.Hosting.Redis", false)]
    public void SdkDirectiveRegex_MatchesValidDirectivesOnly(string directive, bool shouldMatch)
    {
        var regex = ProjectUpdater.SdkDirectiveRegex();
        var match = regex.IsMatch(directive);
 
        Assert.Equal(shouldMatch, match);
    }
 
    [Theory]
    [InlineData("#:package Aspire.Hosting.AppHost@13.2.1", true)]
    [InlineData("  #:package Aspire.Hosting.AppHost@13.2.1", true)]
    [InlineData("#:package aspire.hosting.apphost@13.2.1", true)]
    [InlineData("#:package Aspire.Hosting.AppHost@*", true)]
    [InlineData("#:package Aspire.Hosting.AppHost@10.0.0-preview.1.26264.14", true)]
    [InlineData("#:package Aspire.Hosting.Redis@13.2.1", false)]
    [InlineData("#:sdk Aspire.AppHost.Sdk@13.2.1", false)]
    [InlineData("// #:package Aspire.Hosting.AppHost@13.2.1", false)]
    [InlineData("#:package Aspire.Hosting.AppHost", false)]
    [InlineData("#:package Aspire.Hosting.AppHost@", false)]
    public void LegacyAppHostPackageDirectiveRegex_MatchesOnlyAppHostDirective(string line, bool shouldMatch)
    {
        var regex = ProjectUpdater.LegacyAppHostPackageDirectiveRegex();
        Assert.Equal(shouldMatch, regex.IsMatch(line));
    }
 
    [Fact]
    public async Task UpdateProjectFileAsync_ManagePackageVersionsCentrallyFalse_UpdatesLocalProjectFile()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var appHostFolder = workspace.CreateDirectory("UpdateTester.AppHost");
        var appHostProjectFile = new FileInfo(Path.Combine(appHostFolder.FullName, "UpdateTester.AppHost.csproj"));
 
        var directoryPackagesPropsFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "Directory.Packages.props"));
 
        // Create AppHost project file with ManagePackageVersionsCentrally set to false
        await File.WriteAllTextAsync(
            appHostProjectFile.FullName,
            """
            <Project Sdk="Microsoft.NET.Sdk">
                <Sdk Name="Aspire.AppHost.Sdk" Version="9.5.2" />
                <PropertyGroup>
                    <OutputType>Exe</OutputType>
                    <TargetFramework>net9.0</TargetFramework>
                    <ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally>
                </PropertyGroup>
                <ItemGroup>
                    <PackageReference Include="Aspire.Hosting.Redis" Version="9.5.2" />
                </ItemGroup>
            </Project>
            """);
 
        // Create Directory.Packages.props (which should be ignored due to ManagePackageVersionsCentrally=false)
        await File.WriteAllTextAsync(
            directoryPackagesPropsFile.FullName,
            """
            <Project>
                <PropertyGroup>
                    <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
                </PropertyGroup>
                <ItemGroup>
                </ItemGroup>
            </Project>
            """);
 
        var packagesAddsExecuted = new List<(FileInfo ProjectFile, string PackageId, string PackageVersion, string? PackageSource, bool NoRestore)>();
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, config =>
        {
            config.DotNetCliRunnerFactory = (sp) =>
            {
                return new TestDotNetCliRunner()
                {
                    SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _, _) =>
                    {
                        var packages = new List<NuGetPackageCli>();
 
                        packages.Add(query switch
                        {
                            "Aspire.AppHost.Sdk" => new NuGetPackageCli { Id = "Aspire.AppHost.Sdk", Version = "9.6.0", Source = "nuget.org" },
                            "Aspire.Hosting.Redis" => new NuGetPackageCli { Id = "Aspire.Hosting.Redis", Version = "9.6.0", Source = "nuget.org" },
                            _ => throw new InvalidOperationException($"Unexpected package query: {query}"),
                        });
 
                        return (0, packages.ToArray());
                    },
                    GetProjectItemsAndPropertiesAsyncCallback = (projectFile, items, properties, options, cancellationToken) =>
                    {
                        var itemsAndProperties = new JsonObject();
                        itemsAndProperties.WithSdkVersion("9.5.2");
 
                        // Set ManagePackageVersionsCentrally to false
                        itemsAndProperties.WithProperty("ManagePackageVersionsCentrally", "false");
 
                        // Package has version in the project file (not in Directory.Packages.props)
                        itemsAndProperties.WithPackageReference("Aspire.Hosting.Redis", "9.5.2");
 
                        var json = itemsAndProperties.ToJsonString();
                        var document = JsonDocument.Parse(json);
                        return (0, document);
                    },
                    AddPackageAsyncCallback = (projectFile, packageId, packageVersion, source, noRestore, _, _) =>
                    {
                        packagesAddsExecuted.Add((projectFile, packageId, packageVersion, source!, noRestore));
                        return 0;
                    }
                };
            };
 
            config.InteractionServiceFactory = (sp) =>
            {
                var interactionService = new TestInteractionService();
                interactionService.ConfirmCallback = (promptText, defaultValue) =>
                {
                    return true;
                };
 
                return interactionService;
            };
        });
        using var provider = services.BuildServiceProvider();
 
        var logger = provider.GetRequiredService<ILogger<ProjectUpdater>>();
        var runner = provider.GetRequiredService<IDotNetCliRunner>();
        var interactionService = provider.GetRequiredService<IInteractionService>();
        var cache = provider.GetRequiredService<IMemoryCache>();
        var executionContext = CreateExecutionContext(workspace.WorkspaceRoot);
        var packagingService = provider.GetRequiredService<IPackagingService>();
 
        var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
        var selectedChannel = channels.Single(c => c.Name == "default");
 
        var projectUpdater = provider.GetRequiredService<IProjectUpdater>();
        var updateResult = await projectUpdater.UpdateProjectAsync(CreateUpdateContext(appHostProjectFile, selectedChannel)).DefaultTimeout();
 
        Assert.True(updateResult.UpdatedApplied);
 
        // Verify that the package was updated via dotnet add package (traditional management),
        // not by updating Directory.Packages.props
        Assert.Single(packagesAddsExecuted);
        var packageUpdate = packagesAddsExecuted.Single();
        Assert.Equal("Aspire.Hosting.Redis", packageUpdate.PackageId);
        Assert.Equal("9.6.0", packageUpdate.PackageVersion);
        Assert.Equal(appHostProjectFile.FullName, packageUpdate.ProjectFile.FullName);
 
        // Verify Directory.Packages.props was NOT modified (should still be empty)
        var directoryPackagesContent = await File.ReadAllTextAsync(directoryPackagesPropsFile.FullName);
        Assert.DoesNotContain("Aspire.Hosting.Redis", directoryPackagesContent);
    }
 
    [Theory]
    [InlineData(".csproj")]
    [InlineData(".fsproj")]
    [InlineData(".vbproj")]
    public async Task UpdateSdkVersionInCsprojAppHostAsync_MigratesFromOldFormatToNewFormat(string projectExtension)
    {
        // Arrange - tests migration from old <Sdk Name="..."> to new <Project Sdk="..."> format
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var projectFile = Path.Combine(workspace.WorkspaceRoot.FullName, $"AppHost{projectExtension}");
        var originalContent = """
            <Project Sdk="Microsoft.NET.Sdk">
                <Sdk Name="Aspire.AppHost.Sdk" Version="9.5.0" />
                <PropertyGroup>
                    <OutputType>Exe</OutputType>
                    <TargetFramework>net9.0</TargetFramework>
                </PropertyGroup>
            </Project>
            """;
 
        await File.WriteAllTextAsync(projectFile, originalContent);
 
        var package = new NuGetPackageCli { Id = "Aspire.AppHost.Sdk", Version = "13.0.2", Source = "nuget.org" };
 
        // Act
        await ProjectUpdater.UpdateSdkVersionInProjectAppHostAsync(new FileInfo(projectFile), package).DefaultTimeout();
 
        // Assert
        var updatedContent = await File.ReadAllTextAsync(projectFile);
        await Verify(updatedContent, extension: "xml")
            .UseFileName($"ProjectUpdaterTests.{nameof(UpdateSdkVersionInCsprojAppHostAsync_MigratesFromOldFormatToNewFormat)}")
            .DisableRequireUniquePrefix();
    }
 
    [Theory]
    [InlineData(".csproj")]
    [InlineData(".fsproj")]
    [InlineData(".vbproj")]
    public async Task UpdateSdkVersionInCsprojAppHostAsync_UpdatesExistingNewFormat(string projectExtension)
    {
        // Arrange - tests updating a project already using the new format
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var projectFile = Path.Combine(workspace.WorkspaceRoot.FullName, $"AppHost{projectExtension}");
        var originalContent = """
            <Project Sdk="Aspire.AppHost.Sdk/13.0.1">
                <PropertyGroup>
                    <OutputType>Exe</OutputType>
                    <TargetFramework>net10.0</TargetFramework>
                </PropertyGroup>
            </Project>
            """;
 
        await File.WriteAllTextAsync(projectFile, originalContent);
 
        var package = new NuGetPackageCli { Id = "Aspire.AppHost.Sdk", Version = "13.0.2", Source = "nuget.org" };
 
        // Act
        await ProjectUpdater.UpdateSdkVersionInProjectAppHostAsync(new FileInfo(projectFile), package).DefaultTimeout();
 
        // Assert
        var updatedContent = await File.ReadAllTextAsync(projectFile);
        await Verify(updatedContent, extension: "xml")
            .UseFileName($"ProjectUpdaterTests.{nameof(UpdateSdkVersionInCsprojAppHostAsync_UpdatesExistingNewFormat)}")
            .DisableRequireUniquePrefix();
    }
 
    [Theory]
    [InlineData(".csproj")]
    [InlineData(".fsproj")]
    [InlineData(".vbproj")]
    public async Task UpdateSdkVersionInCsprojAppHostAsync_RemovesAspireHostingAppHostPackageReference(string projectExtension)
    {
        // Arrange - tests removal of obsolete Aspire.Hosting.AppHost package reference
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var projectFile = Path.Combine(workspace.WorkspaceRoot.FullName, $"AppHost{projectExtension}");
        var originalContent = """
            <Project Sdk="Microsoft.NET.Sdk">
                <Sdk Name="Aspire.AppHost.Sdk" Version="9.5.0" />
                <PropertyGroup>
                    <OutputType>Exe</OutputType>
                    <TargetFramework>net9.0</TargetFramework>
                </PropertyGroup>
                <ItemGroup>
                    <PackageReference Include="Aspire.Hosting.AppHost" Version="9.5.0" />
                    <PackageReference Include="Aspire.Hosting.Redis" Version="9.5.0" />
                </ItemGroup>
            </Project>
            """;
 
        await File.WriteAllTextAsync(projectFile, originalContent);
 
        var package = new NuGetPackageCli { Id = "Aspire.AppHost.Sdk", Version = "13.0.2", Source = "nuget.org" };
 
        // Act
        await ProjectUpdater.UpdateSdkVersionInProjectAppHostAsync(new FileInfo(projectFile), package).DefaultTimeout();
 
        // Assert
        var updatedContent = await File.ReadAllTextAsync(projectFile);
        await Verify(updatedContent, extension: "xml")
            .UseFileName($"ProjectUpdaterTests.{nameof(UpdateSdkVersionInCsprojAppHostAsync_RemovesAspireHostingAppHostPackageReference)}")
            .DisableRequireUniquePrefix();
    }
 
    [Theory]
    [InlineData(".csproj")]
    [InlineData(".fsproj")]
    [InlineData(".vbproj")]
    public async Task UpdateSdkVersionInCsprojAppHostAsync_RemovesEmptyItemGroupAfterPackageRemoval(string projectExtension)
    {
        // Arrange - tests that empty ItemGroup is removed after Aspire.Hosting.AppHost removal
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var projectFile = Path.Combine(workspace.WorkspaceRoot.FullName, $"AppHost{projectExtension}");
        var originalContent = """
            <Project Sdk="Microsoft.NET.Sdk">
                <Sdk Name="Aspire.AppHost.Sdk" Version="9.5.0" />
                <PropertyGroup>
                    <OutputType>Exe</OutputType>
                    <TargetFramework>net9.0</TargetFramework>
                </PropertyGroup>
                <ItemGroup>
                    <PackageReference Include="Aspire.Hosting.AppHost" Version="9.5.0" />
                </ItemGroup>
            </Project>
            """;
 
        await File.WriteAllTextAsync(projectFile, originalContent);
 
        var package = new NuGetPackageCli { Id = "Aspire.AppHost.Sdk", Version = "13.0.2", Source = "nuget.org" };
 
        // Act
        await ProjectUpdater.UpdateSdkVersionInProjectAppHostAsync(new FileInfo(projectFile), package).DefaultTimeout();
 
        // Assert
        var updatedContent = await File.ReadAllTextAsync(projectFile);
        await Verify(updatedContent, extension: "xml")
            .UseFileName($"ProjectUpdaterTests.{nameof(UpdateSdkVersionInCsprojAppHostAsync_RemovesEmptyItemGroupAfterPackageRemoval)}")
            .DisableRequireUniquePrefix();
    }
 
    [Theory]
    [InlineData(".csproj")]
    [InlineData(".fsproj")]
    [InlineData(".vbproj")]
    public async Task UpdateSdkVersionInCsprojAppHostAsync_PreservesOtherSdksInAttribute(string projectExtension)
    {
        // Arrange - tests that other SDKs in the attribute are preserved
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var projectFile = Path.Combine(workspace.WorkspaceRoot.FullName, $"AppHost{projectExtension}");
        var originalContent = """
            <Project Sdk="Aspire.AppHost.Sdk/13.0.1;Microsoft.NET.Sdk.Web">
                <PropertyGroup>
                    <OutputType>Exe</OutputType>
                    <TargetFramework>net10.0</TargetFramework>
                </PropertyGroup>
            </Project>
            """;
 
        await File.WriteAllTextAsync(projectFile, originalContent);
 
        var package = new NuGetPackageCli { Id = "Aspire.AppHost.Sdk", Version = "13.0.2", Source = "nuget.org" };
 
        // Act
        await ProjectUpdater.UpdateSdkVersionInProjectAppHostAsync(new FileInfo(projectFile), package).DefaultTimeout();
 
        // Assert
        var updatedContent = await File.ReadAllTextAsync(projectFile);
        await Verify(updatedContent, extension: "xml")
            .UseFileName($"ProjectUpdaterTests.{nameof(UpdateSdkVersionInCsprojAppHostAsync_PreservesOtherSdksInAttribute)}")
            .DisableRequireUniquePrefix();
    }
 
    [Theory]
    [InlineData(".csproj")]
    [InlineData(".fsproj")]
    [InlineData(".vbproj")]
    public async Task UpdateSdkVersionInCsprojAppHostAsync_DoesNotMatchSimilarSdkName(string projectExtension)
    {
        // Arrange - tests that Aspire.AppHost.SdkFoo doesn't match as the Aspire SDK
        // In this case, the old <Sdk Name="..."> element is used, so it's a migration scenario
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var projectFile = Path.Combine(workspace.WorkspaceRoot.FullName, $"AppHost{projectExtension}");
        var originalContent = """
            <Project Sdk="Aspire.AppHost.SdkFoo/1.0.0">
                <Sdk Name="Aspire.AppHost.Sdk" Version="9.5.0" />
                <PropertyGroup>
                    <OutputType>Exe</OutputType>
                </PropertyGroup>
            </Project>
            """;
 
        await File.WriteAllTextAsync(projectFile, originalContent);
 
        var package = new NuGetPackageCli { Id = "Aspire.AppHost.Sdk", Version = "13.0.2", Source = "nuget.org" };
 
        // Act
        await ProjectUpdater.UpdateSdkVersionInProjectAppHostAsync(new FileInfo(projectFile), package).DefaultTimeout();
 
        // Assert
        var updatedContent = await File.ReadAllTextAsync(projectFile);
        await Verify(updatedContent, extension: "xml")
            .UseFileName($"ProjectUpdaterTests.{nameof(UpdateSdkVersionInCsprojAppHostAsync_DoesNotMatchSimilarSdkName)}")
            .DisableRequireUniquePrefix();
    }
 
    [Theory]
    [InlineData(".csproj")]
    [InlineData(".fsproj")]
    [InlineData(".vbproj")]
    public async Task UpdateSdkVersionInCsprojAppHostAsync_RemovesPackageVersionFromDirectoryPackagesPropsForCpm(string projectExtension)
    {
        // Arrange - simulates a CPM project with an old-format AppHost that has
        // Aspire.Hosting.AppHost in both csproj (as PackageReference) and
        // Directory.Packages.props (as PackageVersion). After SDK migration, the
        // PackageReference is removed from csproj but the orphaned PackageVersion
        // must also be removed to avoid NU1009.
        // See: https://github.com/microsoft/aspire/issues/14550
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var projectFile = Path.Combine(workspace.WorkspaceRoot.FullName, $"AppHost{projectExtension}");
        var originalContent = """
            <Project Sdk="Microsoft.NET.Sdk">
                <Sdk Name="Aspire.AppHost.Sdk" Version="9.5.0" />
                <PropertyGroup>
                    <OutputType>Exe</OutputType>
                    <TargetFramework>net9.0</TargetFramework>
                </PropertyGroup>
                <ItemGroup>
                    <PackageReference Include="Aspire.Hosting.AppHost" />
                    <PackageReference Include="Aspire.Hosting.Redis" />
                </ItemGroup>
            </Project>
            """;
 
        await File.WriteAllTextAsync(projectFile, originalContent);
 
        // Create Directory.Packages.props with CPM enabled and PackageVersion for Aspire.Hosting.AppHost
        var directoryPackagesPropsFile = Path.Combine(workspace.WorkspaceRoot.FullName, "Directory.Packages.props");
        await File.WriteAllTextAsync(directoryPackagesPropsFile, """
            <Project>
                <PropertyGroup>
                    <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
                </PropertyGroup>
                <ItemGroup>
                    <PackageVersion Include="Aspire.Hosting.AppHost" Version="9.5.0" />
                    <PackageVersion Include="Aspire.Hosting.Redis" Version="9.5.0" />
                </ItemGroup>
            </Project>
            """);
 
        var package = new NuGetPackageCli { Id = "Aspire.AppHost.Sdk", Version = "13.0.2", Source = "nuget.org" };
 
        // Act
        await ProjectUpdater.UpdateSdkVersionInProjectAppHostAsync(new FileInfo(projectFile), package).DefaultTimeout();
 
        // Assert - PackageVersion for Aspire.Hosting.AppHost should be removed from Directory.Packages.props
        var updatedPropsContent = await File.ReadAllTextAsync(directoryPackagesPropsFile);
        Assert.DoesNotContain("Aspire.Hosting.AppHost", updatedPropsContent);
        // Other PackageVersion entries should be preserved
        Assert.Contains("Aspire.Hosting.Redis", updatedPropsContent);
 
        // Also verify the csproj was updated correctly
        var updatedCsprojContent = await File.ReadAllTextAsync(projectFile);
        Assert.DoesNotContain("Aspire.Hosting.AppHost", updatedCsprojContent);
        Assert.Contains("Aspire.Hosting.Redis", updatedCsprojContent);
        Assert.Contains("Aspire.AppHost.Sdk/13.0.2", updatedCsprojContent);
    }
 
    // Regression guard for https://github.com/dotnet/aspire/issues/15891.
    //
    // Repro shape: project on a stable channel referencing two or more Aspire integration
    // packages whose stable versions are not carried by the daily-style Explicit channel
    // being switched to. The bug was that ProjectUpdater rewrote nuget.config (adding a
    // packageSourceMapping pinning Aspire* to the daily feed) and then ran 'dotnet package add'
    // for each package one at a time *with restore enabled*. The first restore happened against
    // a half-updated reference graph: the just-bumped package existed in the daily feed but the
    // others were still on stable versions that didn't, and the new mapping blocked fallback
    // to nuget.org -> NU1103.
    //
    // The fix passes --no-restore to per-package adds and runs a single deferred restore at the
    // end. This test pins both halves of that contract:
    //   1. every per-package add was invoked with noRestore=true, and
    //   2. exactly one restore was invoked, and
    //   3. the restore came after all the package adds (not interleaved with them).
    [Fact]
    public async Task UpdateProjectFileAsync_AppliesAllPackageEditsBeforeFinalRestore()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var appHostFolder = workspace.CreateDirectory("Issue15891.AppHost");
        var appHostProjectFile = new FileInfo(Path.Combine(appHostFolder.FullName, "Issue15891.AppHost.csproj"));
 
        await File.WriteAllTextAsync(
            appHostProjectFile.FullName,
            """
            <Project Sdk="Microsoft.NET.Sdk">
                <Sdk Name="Aspire.AppHost.Sdk" Version="9.4.1" />
            </Project>
            """);
 
        // Use a single shared list so we can assert that all 'add's happened before the 'restore'.
        // Recording entries as strings keeps the assertion readable when it fails.
        var calls = new List<string>();
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, config =>
        {
            config.DotNetCliRunnerFactory = (sp) =>
            {
                return new TestDotNetCliRunner()
                {
                    SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _, _) =>
                    {
                        var package = query switch
                        {
                            "Aspire.AppHost.Sdk" => new NuGetPackageCli { Id = "Aspire.AppHost.Sdk", Version = "9.5.0-preview.1", Source = "daily" },
                            "Aspire.Hosting.AppHost" => new NuGetPackageCli { Id = "Aspire.Hosting.AppHost", Version = "9.5.0-preview.1", Source = "daily" },
                            "Aspire.Hosting.Redis" => new NuGetPackageCli { Id = "Aspire.Hosting.Redis", Version = "9.5.0-preview.1", Source = "daily" },
                            "Aspire.Hosting.PostgreSQL" => new NuGetPackageCli { Id = "Aspire.Hosting.PostgreSQL", Version = "9.5.0-preview.1", Source = "daily" },
                            "Aspire.Hosting.Kafka" => new NuGetPackageCli { Id = "Aspire.Hosting.Kafka", Version = "9.5.0-preview.1", Source = "daily" },
                            _ => throw new InvalidOperationException($"Unexpected package query: {query}"),
                        };
 
                        return (0, new[] { package });
                    },
 
                    GetProjectItemsAndPropertiesAsyncCallback = (projectFile, _, _, _, _) =>
                    {
                        var itemsAndProperties = new JsonObject();
 
                        if (projectFile.FullName == appHostProjectFile.FullName)
                        {
                            itemsAndProperties.WithSdkVersion("9.4.1");
                            itemsAndProperties.WithPackageReference("Aspire.Hosting.AppHost", "9.4.1");
                            // Three integration packages on the prior stable version - this is the
                            // shape that fails in the original issue: the first per-package restore
                            // would resolve the new daily Redis but reject the still-stable PostgreSQL
                            // and Kafka because the new packageSourceMapping prevents fallback to
                            // nuget.org for Aspire*.
                            itemsAndProperties.WithPackageReference("Aspire.Hosting.Redis", "9.4.1");
                            itemsAndProperties.WithPackageReference("Aspire.Hosting.PostgreSQL", "9.4.1");
                            itemsAndProperties.WithPackageReference("Aspire.Hosting.Kafka", "9.4.1");
                        }
                        else
                        {
                            throw new InvalidOperationException($"Unexpected project file: {projectFile.FullName}");
                        }
 
                        var json = itemsAndProperties.ToJsonString();
                        var document = JsonDocument.Parse(json);
                        return (0, document);
                    },
 
                    AddPackageAsyncCallback = (projectFile, packageId, packageVersion, source, noRestore, _, _) =>
                    {
                        calls.Add($"add:{packageId}@{packageVersion}:noRestore={noRestore}");
                        return 0;
                    },
 
                    RestoreAsyncCallback = (projectFilePath, _, _) =>
                    {
                        calls.Add($"restore:{projectFilePath.Name}");
                        return 0;
                    },
                };
            };
 
            config.InteractionServiceFactory = (s) => new TestInteractionService();
        });
 
        using var provider = services.BuildServiceProvider();
        var packagingService = provider.GetRequiredService<IPackagingService>();
 
        // 'daily' is an Explicit channel (see PackagingService.CreateExplicitChannel), which is
        // the trigger condition for the NuGetConfigMerger path that surfaces the original bug.
        var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
        var dailyChannel = channels.Single(c => c.Name == "daily");
 
        var projectUpdater = provider.GetRequiredService<IProjectUpdater>();
        var updateResult = await projectUpdater.UpdateProjectAsync(CreateUpdateContext(appHostProjectFile, dailyChannel)).DefaultTimeout();
 
        Assert.True(updateResult.UpdatedApplied);
 
        // Every 'dotnet package add' must have been invoked with --no-restore so that NuGet
        // never sees a half-updated reference graph against the new packageSourceMapping.
        var addCalls = calls.Where(c => c.StartsWith("add:", StringComparison.Ordinal)).ToList();
        Assert.NotEmpty(addCalls);
        Assert.All(addCalls, c => Assert.EndsWith(":noRestore=True", c));
 
        // Exactly one restore should have been invoked - the deferred final restore.
        var restoreCalls = calls.Where(c => c.StartsWith("restore:", StringComparison.Ordinal)).ToList();
        var restore = Assert.Single(restoreCalls);
        Assert.Equal($"restore:{appHostProjectFile.Name}", restore);
 
        // The restore must come after every package add. If anyone re-enables per-package
        // restore (or moves the final restore back into / before the loop) this fails.
        Assert.Equal(calls.Count - 1, calls.IndexOf(restore));
    }
 
    [Fact]
    public async Task UpdateProjectFileAsync_StableChannel_DoesNotCreateNuGetConfigWhenNoneExists()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var appHostFolder = workspace.CreateDirectory("UpdateTester.AppHost");
        var appHostProjectFile = new FileInfo(Path.Combine(appHostFolder.FullName, "UpdateTester.AppHost.csproj"));
 
        await File.WriteAllTextAsync(
            appHostProjectFile.FullName,
            """
            <Project Sdk="Microsoft.NET.Sdk">
                <Sdk Name="Aspire.AppHost.Sdk" Version="9.4.1" />
            </Project>
            """);
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, config =>
        {
            config.DotNetCliRunnerFactory = (sp) =>
            {
                return new TestDotNetCliRunner()
                {
                    SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _, _) =>
                    {
                        var package = query switch
                        {
                            "Aspire.AppHost.Sdk" => new NuGetPackageCli { Id = "Aspire.AppHost.Sdk", Version = "13.4.3", Source = "stable" },
                            "Aspire.Hosting.AppHost" => new NuGetPackageCli { Id = "Aspire.Hosting.AppHost", Version = "13.4.3", Source = "stable" },
                            "Aspire.Hosting.Redis" => new NuGetPackageCli { Id = "Aspire.Hosting.Redis", Version = "13.4.3", Source = "stable" },
                            _ => throw new InvalidOperationException($"Unexpected package query: {query}"),
                        };
 
                        return (0, new[] { package });
                    },
 
                    GetProjectItemsAndPropertiesAsyncCallback = (projectFile, _, _, _, _) =>
                    {
                        var itemsAndProperties = new JsonObject();
                        itemsAndProperties.WithSdkVersion("9.4.1");
                        itemsAndProperties.WithPackageReference("Aspire.Hosting.AppHost", "9.4.1");
                        itemsAndProperties.WithPackageReference("Aspire.Hosting.Redis", "9.4.1");
 
                        var document = JsonDocument.Parse(itemsAndProperties.ToJsonString());
                        return (0, document);
                    },
 
                    AddPackageAsyncCallback = (projectFile, packageId, packageVersion, source, noRestore, _, _) => 0,
                };
            };
 
            config.InteractionServiceFactory = (s) => new TestInteractionService();
        });
 
        using var provider = services.BuildServiceProvider();
        var packagingService = provider.GetRequiredService<IPackagingService>();
 
        // The stable channel is Explicit but maps Aspire* only to nuget.org (the ambient source),
        // so ShouldCreateNuGetConfig() is false and no project-level config is required.
        var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
        var stableChannel = channels.Single(c => c.Name == "stable");
 
        var projectUpdater = provider.GetRequiredService<IProjectUpdater>();
        var updateResult = await projectUpdater.UpdateProjectAsync(CreateUpdateContext(appHostProjectFile, stableChannel)).DefaultTimeout();
 
        Assert.True(updateResult.UpdatedApplied);
 
        // Regression guard for https://github.com/microsoft/aspire/issues/18124: updating to the
        // stable channel must NOT drop a redundant <clear/>-based NuGet.config. None existed, so
        // none should have been created anywhere under the workspace.
        var nugetConfigs = workspace.WorkspaceRoot
            .EnumerateFiles("*", SearchOption.AllDirectories)
            .Where(f => string.Equals(f.Name, "nuget.config", StringComparison.OrdinalIgnoreCase))
            .ToList();
 
        Assert.Empty(nugetConfigs);
    }
 
    [Fact]
    public async Task UpdateProjectFileAsync_StableChannel_UpdatesExistingNuGetConfig()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var appHostFolder = workspace.CreateDirectory("UpdateTester.AppHost");
        var appHostProjectFile = new FileInfo(Path.Combine(appHostFolder.FullName, "UpdateTester.AppHost.csproj"));
 
        await File.WriteAllTextAsync(
            appHostProjectFile.FullName,
            """
            <Project Sdk="Microsoft.NET.Sdk">
                <Sdk Name="Aspire.AppHost.Sdk" Version="9.4.1" />
            </Project>
            """);
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, config =>
        {
            config.DotNetCliRunnerFactory = (sp) =>
            {
                return new TestDotNetCliRunner()
                {
                    SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _, _) =>
                    {
                        var package = query switch
                        {
                            "Aspire.AppHost.Sdk" => new NuGetPackageCli { Id = "Aspire.AppHost.Sdk", Version = "13.4.3", Source = "stable" },
                            "Aspire.Hosting.AppHost" => new NuGetPackageCli { Id = "Aspire.Hosting.AppHost", Version = "13.4.3", Source = "stable" },
                            "Aspire.Hosting.Redis" => new NuGetPackageCli { Id = "Aspire.Hosting.Redis", Version = "13.4.3", Source = "stable" },
                            _ => throw new InvalidOperationException($"Unexpected package query: {query}"),
                        };
 
                        return (0, new[] { package });
                    },
 
                    GetProjectItemsAndPropertiesAsyncCallback = (projectFile, _, _, _, _) =>
                    {
                        var itemsAndProperties = new JsonObject();
                        itemsAndProperties.WithSdkVersion("9.4.1");
                        itemsAndProperties.WithPackageReference("Aspire.Hosting.AppHost", "9.4.1");
                        itemsAndProperties.WithPackageReference("Aspire.Hosting.Redis", "9.4.1");
 
                        var document = JsonDocument.Parse(itemsAndProperties.ToJsonString());
                        return (0, document);
                    },
 
                    AddPackageAsyncCallback = (projectFile, packageId, packageVersion, source, noRestore, _, _) => 0,
                };
            };
 
            config.InteractionServiceFactory = (s) => new TestInteractionService();
        });
 
        using var provider = services.BuildServiceProvider();
 
        // The update path writes/refreshes the config in the recommended directory, which falls
        // back to the CLI working directory when only global configs are discovered (the default
        // TestDotNetCliRunner behavior). Seed an existing config there with a private feed and no
        // packageSourceMapping so the merge is a real change.
        var executionContext = provider.GetRequiredService<Aspire.Cli.CliExecutionContext>();
        var existingConfigFile = new FileInfo(Path.Combine(executionContext.WorkingDirectory.FullName, "nuget.config"));
        await File.WriteAllTextAsync(
            existingConfigFile.FullName,
            """
            <?xml version="1.0" encoding="utf-8"?>
            <configuration>
              <packageSources>
                <add key="contoso" value="https://contoso.example/feed/v3/index.json" />
              </packageSources>
            </configuration>
            """);
 
        var packagingService = provider.GetRequiredService<IPackagingService>();
        var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
        var stableChannel = channels.Single(c => c.Name == "stable");
 
        var projectUpdater = provider.GetRequiredService<IProjectUpdater>();
        var updateResult = await projectUpdater.UpdateProjectAsync(CreateUpdateContext(appHostProjectFile, stableChannel)).DefaultTimeout();
 
        Assert.True(updateResult.UpdatedApplied);
 
        // Even though the stable channel never *creates* a config, an existing one is still
        // refreshed so feeds left over from a previous channel get cleaned up (#18124). The merge
        // preserves the user's feed and adds a packageSourceMapping that wasn't there before.
        Assert.True(existingConfigFile.Exists);
        var updatedContent = await File.ReadAllTextAsync(existingConfigFile.FullName);
        Assert.Contains("contoso", updatedContent, StringComparison.Ordinal);
        Assert.Contains("packageSourceMapping", updatedContent, StringComparison.Ordinal);
    }
 
    [Fact]
    public async Task UpdateProjectFileAsync_DailyChannel_CreatesNuGetConfigWhenNoneExists()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var appHostFolder = workspace.CreateDirectory("UpdateTester.AppHost");
        var appHostProjectFile = new FileInfo(Path.Combine(appHostFolder.FullName, "UpdateTester.AppHost.csproj"));
 
        await File.WriteAllTextAsync(
            appHostProjectFile.FullName,
            """
            <Project Sdk="Microsoft.NET.Sdk">
                <Sdk Name="Aspire.AppHost.Sdk" Version="9.4.1" />
            </Project>
            """);
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, config =>
        {
            config.DotNetCliRunnerFactory = (sp) =>
            {
                return new TestDotNetCliRunner()
                {
                    SearchPackagesAsyncCallback = (_, query, _, _, _, _, _, _, _, _) =>
                    {
                        var package = query switch
                        {
                            "Aspire.AppHost.Sdk" => new NuGetPackageCli { Id = "Aspire.AppHost.Sdk", Version = "13.5.0-preview.1", Source = "daily" },
                            "Aspire.Hosting.AppHost" => new NuGetPackageCli { Id = "Aspire.Hosting.AppHost", Version = "13.5.0-preview.1", Source = "daily" },
                            "Aspire.Hosting.Redis" => new NuGetPackageCli { Id = "Aspire.Hosting.Redis", Version = "13.5.0-preview.1", Source = "daily" },
                            _ => throw new InvalidOperationException($"Unexpected package query: {query}"),
                        };
 
                        return (0, new[] { package });
                    },
 
                    GetProjectItemsAndPropertiesAsyncCallback = (projectFile, _, _, _, _) =>
                    {
                        var itemsAndProperties = new JsonObject();
                        itemsAndProperties.WithSdkVersion("9.4.1");
                        itemsAndProperties.WithPackageReference("Aspire.Hosting.AppHost", "9.4.1");
                        itemsAndProperties.WithPackageReference("Aspire.Hosting.Redis", "9.4.1");
 
                        var document = JsonDocument.Parse(itemsAndProperties.ToJsonString());
                        return (0, document);
                    },
 
                    AddPackageAsyncCallback = (projectFile, packageId, packageVersion, source, noRestore, _, _) => 0,
                };
            };
 
            config.InteractionServiceFactory = (s) => new TestInteractionService();
        });
 
        using var provider = services.BuildServiceProvider();
        var packagingService = provider.GetRequiredService<IPackagingService>();
 
        // Contrast with the stable channel: daily maps Aspire* to a custom feed, so
        // ShouldCreateNuGetConfig() is true and a project-level config must be created.
        var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
        var dailyChannel = channels.Single(c => c.Name == "daily");
 
        var projectUpdater = provider.GetRequiredService<IProjectUpdater>();
        var updateResult = await projectUpdater.UpdateProjectAsync(CreateUpdateContext(appHostProjectFile, dailyChannel)).DefaultTimeout();
 
        Assert.True(updateResult.UpdatedApplied);
 
        var nugetConfigs = workspace.WorkspaceRoot
            .EnumerateFiles("*", SearchOption.AllDirectories)
            .Where(f => string.Equals(f.Name, "nuget.config", StringComparison.OrdinalIgnoreCase))
            .ToList();
 
        var createdConfig = Assert.Single(nugetConfigs);
        var content = await File.ReadAllTextAsync(createdConfig.FullName);
        Assert.Contains("packageSourceMapping", content, StringComparison.Ordinal);
    }
}
 
internal static class MSBuildJsonDocumentExtensions
{
    public static JsonObject WithSdkVersion(this JsonObject root, string sdkVersion)
    {
        root.WithMSBuildOutput(); // Ensure Items structure exists
 
        JsonObject properties = new JsonObject();
        if (!root.TryAdd("Properties", properties))
        {
            properties = root["Properties"]!.AsObject();
        }
 
        properties.Add("AspireHostingSDKVersion", JsonValue.Create<string>(sdkVersion));
        return root;
    }
 
    public static JsonObject WithMSBuildOutput(this JsonObject root)
    {
        JsonObject items = new JsonObject();
        items.Add("ProjectReference", new JsonArray());
        items.Add("PackageReference", new JsonArray());
 
        if (!root.TryAdd("Items", items))
        {
            items = root["Items"]!.AsObject();
 
            // Ensure both arrays exist
            if (!items.ContainsKey("ProjectReference"))
            {
                items.Add("ProjectReference", new JsonArray());
            }
            if (!items.ContainsKey("PackageReference"))
            {
                items.Add("PackageReference", new JsonArray());
            }
        }
 
        return root;
    }
 
    public static JsonObject WithPackageReference(this JsonObject root, string packageId, string packageVersion)
    {
        root.WithMSBuildOutput();
        var items = root["Items"]!.AsObject();
        var packageReferences = items["PackageReference"]!.AsArray();
 
        JsonObject newPackageReference = new JsonObject
        {
            { "Identity", JsonValue.Create<string>(packageId) },
            { "Version", JsonValue.Create<string>(packageVersion) }
        };
        packageReferences.Add(newPackageReference);
 
        return root;
    }
 
    public static JsonObject WithPackageReferenceWithoutVersion(this JsonObject root, string packageId)
    {
        root.WithMSBuildOutput();
        var items = root["Items"]!.AsObject();
        var packageReferences = items["PackageReference"]!.AsArray();
 
        JsonObject newPackageReference = new JsonObject
        {
            { "Identity", JsonValue.Create<string>(packageId) }
            // No Version property for CPM
        };
        packageReferences.Add(newPackageReference);
 
        return root;
    }
 
    public static JsonObject WithProjectReference(this JsonObject root, string fullPath)
    {
        root.WithMSBuildOutput();
        var items = root["Items"]!.AsObject();
        var projectReferences = items["ProjectReference"]!.AsArray();
 
        JsonObject newProjectReference = new JsonObject
        {
            { "FullPath", JsonValue.Create<string>(fullPath) }
        };
        projectReferences.Add(newProjectReference);
 
        return root;
    }
 
    public static JsonObject WithProperty(this JsonObject root, string propertyName, string propertyValue)
    {
        JsonObject properties = new JsonObject();
        if (!root.TryAdd("Properties", properties))
        {
            properties = root["Properties"]!.AsObject();
        }
 
        properties.Add(propertyName, JsonValue.Create<string>(propertyValue));
        return root;
    }
}