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;
 
namespace Aspire.Cli.Tests.Projects;
 
public class ProjectUpdaterTests(ITestOutputHelper outputHelper)
{
    [Fact]
    public async Task UpdateProjectFileAsync_DoesAttemptToUpdateIfNoUpdatesRequired()
    {
        using var workspace = TemporaryWorkspace.Create(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 TestConsoleInteractionService();
                interactionService.ConfirmCallback = (promptText, defaultValue) =>
                {
                    throw new InvalidOperationException("Should not prompt when no work required.");
                };
 
                return interactionService;
            };
        });
        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();
        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 = new ProjectUpdater(logger, runner, interactionService, cache, executionContext);
        var updateResult = await projectUpdater.UpdateProjectAsync(appHostProjectFile, selectedChannel).WaitAsync(CliTestConstants.DefaultTimeout);
        Assert.False(updateResult.UpdatedApplied);
    }
 
    [Fact]
    public async Task UpdateProjectFileAsync_CanUpdateFromStableToDaily()
    {
        using var workspace = TemporaryWorkspace.Create(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 packagesAddsExecuted = new List<(FileInfo ProjectFile, string PackageId, string PackageVersion, string? PackageSource)>();
        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?, DotNetCliRunnerInvocationOptions, CancellationToken, int
                    AddPackageAsyncCallback = (projectFile, packageId, packageVersion, source, _, _) =>
                    {
                        packagesAddsExecuted.Add((projectFile, packageId, packageVersion, source!));
                        return 0;
                    }
                };
            };
 
            config.InteractionServiceFactory = (s) =>
            {
                var interactionService = new TestConsoleInteractionService();
                return interactionService;
            };
        });
        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();
        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 = new ProjectUpdater(logger, runner, interactionService, cache, executionContext);
        var updateResult = await projectUpdater.UpdateProjectAsync(appHostProjectFile, selectedChannel).WaitAsync(CliTestConstants.DefaultTimeout);
 
        Assert.True(updateResult.UpdatedApplied);
        Assert.Collection(
            packagesAddsExecuted,
            item =>
            {
                Assert.Equal("Aspire.Hosting.AppHost", item.PackageId);
                Assert.Equal("9.5.0-preview.1", item.PackageVersion);
                Assert.Null(item.PackageSource); // Should be null because of --no-restore behavior.
                Assert.Equal(appHostProjectFile.FullName, item.ProjectFile.FullName);
            },
            item =>
            {
                Assert.Equal("Aspire.Hosting.Redis", item.PackageId);
                Assert.Equal("9.5.0-preview.1", item.PackageVersion);
                Assert.Null(item.PackageSource); // Should be null because of --no-restore behavior.
                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);
                Assert.Null(item.PackageSource); // Should be null because of --no-restore behavior.
                Assert.Equal(webAppProjectFile.FullName, item.ProjectFile.FullName);
            },
            item =>
            {
                Assert.Equal("Microsoft.Extensions.ServiceDiscovery", item.PackageId);
                Assert.Equal("9.5.0-preview.1", item.PackageVersion);
                Assert.Null(item.PackageSource); // Should be null because of --no-restore behavior.
                Assert.Equal(serviceDefaultsProjectFile.FullName, item.ProjectFile.FullName);
            }
        );
    }
 
    [Fact]
    public async Task UpdateProjectFileAsync_CanUpdateFromDailyToStableWhereOnePackageIsUnstableOnly()
    {
        using var workspace = TemporaryWorkspace.Create(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 packagesAddsExecuted = new List<(FileInfo ProjectFile, string PackageId, string PackageVersion, string? PackageSource)>();
        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?, DotNetCliRunnerInvocationOptions, CancellationToken, int
                    AddPackageAsyncCallback = (projectFile, packageId, packageVersion, source, _, _) =>
                    {
                        packagesAddsExecuted.Add((projectFile, packageId, packageVersion, source!));
                        return 0;
                    }
                };
            };
 
            config.InteractionServiceFactory = (s) =>
            {
                var interactionService = new TestConsoleInteractionService();
                return interactionService;
            };
        });
        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();
        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 = new ProjectUpdater(logger, runner, interactionService, cache, executionContext);
        var updateResult = await projectUpdater.UpdateProjectAsync(appHostProjectFile, selectedChannel).WaitAsync(CliTestConstants.DefaultTimeout);
 
        Assert.True(updateResult.UpdatedApplied);
        Assert.Collection(
            packagesAddsExecuted,
            item =>
            {
                Assert.Equal("Aspire.Hosting.AppHost", item.PackageId);
                Assert.Equal("9.4.1", item.PackageVersion);
                Assert.Null(item.PackageSource); // Should be null because of --no-restore behavior.
                Assert.Equal(appHostProjectFile.FullName, item.ProjectFile.FullName);
            },
            item =>
            {
                Assert.Equal("Aspire.Hosting.Redis", item.PackageId);
                Assert.Equal("9.4.1", item.PackageVersion);
                Assert.Null(item.PackageSource); // Should be null because of --no-restore behavior.
                Assert.Equal(appHostProjectFile.FullName, item.ProjectFile.FullName);
            },
            item =>
            {
                Assert.Equal("Aspire.Hosting.Docker", item.PackageId);
                Assert.Equal("9.4.1-preview.1", item.PackageVersion);
                Assert.Null(item.PackageSource); // Should be null because of --no-restore behavior.
                Assert.Equal(appHostProjectFile.FullName, item.ProjectFile.FullName);
            },
            item =>
            {
                Assert.Equal("Aspire.StackExchange.Redis.OutputCaching", item.PackageId);
                Assert.Equal("9.4.1", item.PackageVersion);
                Assert.Null(item.PackageSource); // Should be null because of --no-restore behavior.
                Assert.Equal(webAppProjectFile.FullName, item.ProjectFile.FullName);
            },
            item =>
            {
                Assert.Equal("Microsoft.Extensions.ServiceDiscovery", item.PackageId);
                Assert.Equal("9.4.1", item.PackageVersion);
                Assert.Null(item.PackageSource); // Should be null because of --no-restore behavior.
                Assert.Equal(serviceDefaultsProjectFile.FullName, item.ProjectFile.FullName);
            }
        );
    }
 
    private static Aspire.Cli.CliExecutionContext CreateExecutionContext(DirectoryInfo workingDirectory)
    {
        // NOTE: This would normally be in the users home directory, but for tests we create
        //       it in the temporary workspace directory.
        var settingsDirectory = workingDirectory.CreateSubdirectory(".aspire");
        var hivesDirectory = settingsDirectory.CreateSubdirectory("hives");
        return new CliExecutionContext(workingDirectory, hivesDirectory);
    }
}
 
internal static class MSBuildJsonDocumentExtensions
{
    public static JsonObject WithSdkVersion(this JsonObject root, string sdkVersion)
    {
        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 WithPackageReference(this JsonObject root, string packageId, string packageVersion)
    {
        JsonObject items = new JsonObject();
        items.Add("ProjectReference", new JsonArray());
        items.Add("PackageReference", new JsonArray());
 
        if (!root.TryAdd("Items", items))
        {
            items = root["Items"]!.AsObject();
        }
 
        JsonArray packageReferences = new JsonArray();
        if (!items.TryAdd("PackageReference", packageReferences))
        {
            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 WithProjectReference(this JsonObject root, string fullPath)
    {
        JsonObject items = new JsonObject();
        if (!root.TryAdd("Items", items))
        {
            items = root["Items"]!.AsObject();
        }
 
        JsonArray projectReferences = new JsonArray();
        if (!items.TryAdd("ProjectReference", projectReferences))
        {
            projectReferences = items["ProjectReference"]!.AsArray();
        }
 
        JsonObject newProjectReference = new JsonObject
        {
            { "FullPath", JsonValue.Create<string>(fullPath) }
        };
        projectReferences.Add(newProjectReference);
 
        return root;
    }
}