File: Commands\UpdateCommandRepositoryToolsTests.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 System.Xml.Linq;
using Aspire.Cli.Commands;
using Aspire.Cli.Npm;
using Aspire.Cli.Packaging;
using Aspire.Cli.Projects;
using Aspire.Cli.Resources;
using Aspire.Cli.Tests.TestServices;
using Aspire.Cli.Tests.Utils;
using Microsoft.AspNetCore.InternalTesting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Semver;
 
namespace Aspire.Cli.Tests.Commands;
 
public class UpdateCommandRepositoryToolsTests(ITestOutputHelper outputHelper)
{
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public async Task Update_UpdatesBothManifestsWithoutReplacingExecutable(bool hasAppHost)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var directory = workspace.WorkspaceRoot;
        var (dotnetManifest, npmManifest) = await CreateManifestsAsync(directory);
        var appHost = new FileInfo(Path.Combine(directory.FullName, "AppHost.csproj"));
        if (hasAppHost)
        {
            await File.WriteAllTextAsync(appHost.FullName, "<Project Sdk=\"Microsoft.NET.Sdk\" />");
        }
 
        var projectUpdated = false;
        var interaction = new TestInteractionService();
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            ConfigureUpdates(options, interaction);
            options.ProjectLocatorFactory = _ => new TestProjectLocator
            {
                UseOrFindAppHostProjectFileAsyncCallback = (_, _, _) => hasAppHost
                    ? Task.FromResult<FileInfo?>(appHost)
                    : throw new ProjectLocatorException(ErrorStrings.NoProjectFileFound, ProjectLocatorFailureReason.NoProjectFileFound)
            };
            options.ProjectUpdaterFactory = _ => new TestProjectUpdater
            {
                UpdateProjectAsyncCallback = async (context, cancellationToken) =>
                {
                    projectUpdated = true;
                    var packageJson = JsonNode.Parse(await File.ReadAllTextAsync(npmManifest, cancellationToken))!;
                    Assert.Equal("^13.4.0", packageJson["devDependencies"]![RepositoryToolUpdater.NpmPackageId]!.GetValue<string>());
                    await Assert.Single(context.AdditionalUpdateSteps).Callback();
                    return new ProjectUpdateResult { UpdatedApplied = true };
                }
            };
        });
        using var provider = services.BuildServiceProvider();
 
        var result = await provider.GetRequiredService<RootCommand>()
            .Parse("update --channel stable --yes --non-interactive").InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, result);
        Assert.Equal(hasAppHost, projectUpdated);
        Assert.Equal("13.5.4", JsonNode.Parse(await File.ReadAllTextAsync(dotnetManifest))!["tools"]!["aspire.cli"]!["version"]!.GetValue<string>());
        Assert.Equal("^13.5.4", JsonNode.Parse(await File.ReadAllTextAsync(npmManifest))!["devDependencies"]![RepositoryToolUpdater.NpmPackageId]!.GetValue<string>());
        Assert.Contains(UpdateCommandStrings.RepositoryToolsUpdated, interaction.DisplayedSuccess);
        Assert.Empty(interaction.BooleanPromptCalls);
    }
 
    [Theory]
    [InlineData(true, true, false, "both")]
    [InlineData(false, true, false, "both")]
    [InlineData(true, false, false, "both")]
    [InlineData(false, false, false, "both")]
    [InlineData(false, false, true, "both")]
    [InlineData(false, false, true, "dotnet")]
    [InlineData(false, false, true, "npm")]
    public async Task Update_NewerGuestSdkRequiresRestoringRepositoryToolWithoutReplacingExecutable(bool hasDownloadUrl, bool hasDownloader, bool pinsAlreadyCurrent, string manifestKind)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var (dotnetManifest, npmManifest) = await CreateManifestsAsync(workspace.WorkspaceRoot);
        if (pinsAlreadyCurrent)
        {
            var dotnet = JsonNode.Parse(await File.ReadAllTextAsync(dotnetManifest))!;
            dotnet["tools"]!["aspire.cli"]!["version"] = "99.0.0";
            await File.WriteAllTextAsync(dotnetManifest, dotnet.ToJsonString());
            var npm = JsonNode.Parse(await File.ReadAllTextAsync(npmManifest))!;
            npm["devDependencies"]![RepositoryToolUpdater.NpmPackageId] = "^99.0.0";
            await File.WriteAllTextAsync(npmManifest, npm.ToJsonString());
        }
        var expectedGuidance = new List<string>();
        if (manifestKind is "both" or "dotnet")
        {
            expectedGuidance.Add(UpdateCommandStrings.RestoreRepositoryDotNetTool);
        }
        else
        {
            File.Delete(dotnetManifest);
        }
        if (manifestKind is "both" or "npm")
        {
            expectedGuidance.Add(UpdateCommandStrings.RestoreRepositoryNpmTool);
        }
        else
        {
            File.Delete(npmManifest);
        }
        var originals = new Dictionary<string, byte[]>();
        foreach (var path in new[] { dotnetManifest, npmManifest }.Where(File.Exists))
        {
            originals.Add(path, await File.ReadAllBytesAsync(path));
        }
        var appHost = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.ts"));
        await File.WriteAllTextAsync(appHost.FullName, "// test apphost");
        var projectUpdated = false;
        var interaction = new TestInteractionService();
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            ConfigureUpdates(options, interaction);
            if (!hasDownloader)
            {
                options.CliDownloaderFactory = _ => null!;
            }
            options.NpmRunnerFactory = _ => new FakeNpmRunner
            {
                ResolvePackageAsyncCallback = (_, _, _) => Task.FromResult<NpmPackageInfo?>(new() { Version = SemVersion.Parse("99.0.0") })
            };
            options.ProjectLocatorFactory = _ => new TestProjectLocator
            {
                UseOrFindAppHostProjectFileAsyncCallback = (_, _, _) => Task.FromResult<FileInfo?>(appHost)
            };
            options.AppHostProjectFactory = _ => new TestAppHostProjectFactory
            {
                CanHandleCallback = _ => true,
                LanguageId = "typescript/nodejs",
                DisplayName = "TypeScript (Node.js)",
                DetectionPatterns = ["apphost.ts"],
                UpdatePackagesAsyncCallback = (_, _) =>
                {
                    projectUpdated = true;
                    return Task.FromResult(new UpdatePackagesResult { UpdatesApplied = true });
                }
            };
            options.PackagingServiceFactory = _ => new TestPackagingService
            {
                GetChannelsAsyncCallback = _ => Task.FromResult<IEnumerable<PackageChannel>>(
                [
                    new PackageChannel(PackageChannelNames.Stable, PackageChannelQuality.Stable, [],
                        new FakeNuGetPackageCache(), new TestFeatures(), NullLogger.Instance,
                        cliDownloadBaseUrl: hasDownloadUrl ? "https://example.invalid/cli" : null, pinnedVersion: "99.0.0")
                ])
            };
        });
        using var provider = services.BuildServiceProvider();
 
        var result = await provider.GetRequiredService<RootCommand>()
            .Parse("update --channel stable --yes --non-interactive").InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, result);
        Assert.False(projectUpdated);
        if (pinsAlreadyCurrent)
        {
            foreach (var (path, original) in originals)
            {
                Assert.Equal(original, await File.ReadAllBytesAsync(path));
            }
            Assert.Empty(interaction.DisplayedSuccess);
        }
        else
        {
            Assert.Equal("99.0.0", JsonNode.Parse(await File.ReadAllTextAsync(dotnetManifest))!["tools"]!["aspire.cli"]!["version"]!.GetValue<string>());
            Assert.Equal("^99.0.0", JsonNode.Parse(await File.ReadAllTextAsync(npmManifest))!["devDependencies"]![RepositoryToolUpdater.NpmPackageId]!.GetValue<string>());
            Assert.Equal(UpdateCommandStrings.RepositoryToolsUpdated, Assert.Single(interaction.DisplayedSuccess));
        }
        Assert.Equal(expectedGuidance, interaction.DisplayedMessages
            .Where(message => message.Message == UpdateCommandStrings.RestoreRepositoryDotNetTool || message.Message == UpdateCommandStrings.RestoreRepositoryNpmTool)
            .Select(message => message.Message));
        Assert.Contains(interaction.DisplayedMessages, message => message.Message == UpdateCommandStrings.ProjectUpdateSkippedAfterCliUpdateMessage);
        Assert.Empty(interaction.BooleanPromptCalls);
    }
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public async Task Update_UnsupportedProjectsAreNotTreatedAsMissingAppHost(bool hasRepositoryTools)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var originals = new Dictionary<string, byte[]>();
        if (hasRepositoryTools)
        {
            var (dotnetManifest, npmManifest) = await CreateManifestsAsync(workspace.WorkspaceRoot);
            originals.Add(dotnetManifest, await File.ReadAllBytesAsync(dotnetManifest));
            originals.Add(npmManifest, await File.ReadAllBytesAsync(npmManifest));
        }
        var interaction = new TestInteractionService();
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            ConfigureUpdates(options, interaction);
            options.ProjectLocatorFactory = _ => new TestProjectLocator
            {
                UseOrFindAppHostProjectFileAsyncCallback = (_, _, _) =>
                    throw new ProjectLocatorException(ErrorStrings.NoProjectFileFound, ProjectLocatorFailureReason.UnsupportedProjects)
            };
        });
        using var provider = services.BuildServiceProvider();
 
        var result = await provider.GetRequiredService<RootCommand>()
            .Parse("update --channel stable --yes --non-interactive").InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.SdkNotInstalled, result);
        Assert.Contains(InteractionServiceStrings.NoSupportedAppHostsFound, interaction.DisplayedErrors);
        Assert.Empty(interaction.BooleanPromptCalls);
        Assert.Empty(interaction.DisplayedSuccess);
        foreach (var (path, original) in originals)
        {
            Assert.Equal(original, await File.ReadAllBytesAsync(path));
        }
    }
 
    [Fact]
    public async Task Update_DecliningGuestRepositoryToolUpdateContinuesProjectUpdateWithoutChangingPins()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var (dotnetManifest, npmManifest) = await CreateManifestsAsync(workspace.WorkspaceRoot);
        var originalDotnet = await File.ReadAllBytesAsync(dotnetManifest);
        var originalNpm = await File.ReadAllBytesAsync(npmManifest);
        var appHost = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.ts"));
        await File.WriteAllTextAsync(appHost.FullName, "// test apphost");
        var confirmations = 0;
        var interaction = new TestInteractionService
        {
            ConfirmCallback = (prompt, _) =>
            {
                Assert.Equal(UpdateCommandStrings.PerformUpdatesPrompt, prompt);
                return ++confirmations > 1;
            }
        };
        var projectUpdated = false;
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            ConfigureUpdates(options, interaction);
            options.ProjectLocatorFactory = _ => new TestProjectLocator
            {
                UseOrFindAppHostProjectFileAsyncCallback = (_, _, _) => Task.FromResult<FileInfo?>(appHost)
            };
            options.AppHostProjectFactory = _ => new TestAppHostProjectFactory
            {
                CanHandleCallback = _ => true,
                LanguageId = "typescript/nodejs",
                DisplayName = "TypeScript (Node.js)",
                DetectionPatterns = ["apphost.ts"],
                UpdatePackagesAsyncCallback = async (context, cancellationToken) =>
                {
                    Assert.Empty(context.AdditionalUpdateSteps);
                    projectUpdated = await interaction.PromptConfirmAsync(
                        UpdateCommandStrings.PerformUpdatesPrompt, context.ConfirmBinding, cancellationToken: cancellationToken);
                    return new UpdatePackagesResult { UpdatesApplied = projectUpdated };
                }
            };
            options.PackagingServiceFactory = _ => new TestPackagingService
            {
                GetChannelsAsyncCallback = _ => Task.FromResult<IEnumerable<PackageChannel>>(
                [
                    new PackageChannel(PackageChannelNames.Stable, PackageChannelQuality.Stable, [],
                        new FakeNuGetPackageCache(), new TestFeatures(), NullLogger.Instance,
                        cliDownloadBaseUrl: "https://example.invalid/cli", pinnedVersion: "99.0.0")
                ])
            };
        });
        using var provider = services.BuildServiceProvider();
 
        var result = await provider.GetRequiredService<RootCommand>()
            .Parse("update --channel stable").InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, result);
        Assert.True(projectUpdated);
        Assert.Equal(2, confirmations);
        Assert.Equal(originalDotnet, await File.ReadAllBytesAsync(dotnetManifest));
        Assert.Equal(originalNpm, await File.ReadAllBytesAsync(npmManifest));
        Assert.Empty(interaction.DisplayedSuccess);
    }
 
    [Fact]
    public async Task Update_ExplicitAppHostOnlyUpdatesItsRepository()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var (cwdManifest, _) = await CreateManifestsAsync(workspace.WorkspaceRoot);
        var originalCwdManifest = await File.ReadAllTextAsync(cwdManifest);
        var selectedDirectory = Directory.CreateDirectory(Path.Combine(workspace.WorkspaceRoot.FullName, "selected"));
        var (selectedManifest, _) = await CreateManifestsAsync(selectedDirectory);
        var appHost = Path.Combine(selectedDirectory.FullName, "AppHost.csproj");
        await File.WriteAllTextAsync(appHost, "<Project Sdk=\"Microsoft.NET.Sdk\" />");
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            ConfigureUpdates(options, new TestInteractionService());
            options.ProjectLocatorFactory = _ => new TestProjectLocator
            {
                UseOrFindAppHostProjectFileAsyncCallback = (file, _, _) => Task.FromResult(file)
            };
            options.ProjectUpdaterFactory = _ => new TestProjectUpdater
            {
                UpdateProjectAsyncCallback = async (context, _) =>
                {
                    await Assert.Single(context.AdditionalUpdateSteps).Callback();
                    return new ProjectUpdateResult { UpdatedApplied = true };
                }
            };
        });
        using var provider = services.BuildServiceProvider();
 
        var result = await provider.GetRequiredService<RootCommand>()
            .Parse(["update", "--apphost", appHost, "--channel", "stable", "--yes", "--non-interactive"])
            .InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, result);
        Assert.Equal(originalCwdManifest, await File.ReadAllTextAsync(cwdManifest));
        Assert.Equal("13.5.4", JsonNode.Parse(await File.ReadAllTextAsync(selectedManifest))!["tools"]!["aspire.cli"]!["version"]!.GetValue<string>());
    }
 
    [Fact]
    public async Task Update_SelfDoesNotReadOrChangeRepositoryManifests()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var (dotnetManifest, npmManifest) = await CreateManifestsAsync(workspace.WorkspaceRoot);
        await File.WriteAllTextAsync(npmManifest, "invalid JSON");
        var originalDotNetManifest = await File.ReadAllTextAsync(dotnetManifest);
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            ConfigureUpdates(options, new TestInteractionService());
            options.ProcessPathProviderFactory = _ => new TestProcessPathProvider("/home/test/.dotnet/tools/aspire");
        });
        using var provider = services.BuildServiceProvider();
 
        var result = await provider.GetRequiredService<RootCommand>()
            .Parse("update --self --yes --non-interactive").InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, result);
        Assert.Equal(originalDotNetManifest, await File.ReadAllTextAsync(dotnetManifest));
        Assert.Equal("invalid JSON", await File.ReadAllTextAsync(npmManifest));
    }
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public async Task Update_ResolutionFailureDoesNotChangeEitherManifest(bool hasAppHost)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var (dotnetManifest, npmManifest) = await CreateManifestsAsync(workspace.WorkspaceRoot);
        var originalDotNetManifest = await File.ReadAllTextAsync(dotnetManifest);
        var originalNpmManifest = await File.ReadAllTextAsync(npmManifest);
        var appHost = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj"));
        if (hasAppHost)
        {
            await File.WriteAllTextAsync(appHost.FullName, "<Project Sdk=\"Microsoft.NET.Sdk\" />");
        }
        var interaction = new TestInteractionService();
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            ConfigureUpdates(options, interaction);
            options.NpmRunnerFactory = _ => new FakeNpmRunner();
            options.ProjectLocatorFactory = _ => new TestProjectLocator
            {
                UseOrFindAppHostProjectFileAsyncCallback = (_, _, _) => Task.FromResult(hasAppHost ? appHost : null)
            };
            options.ProjectUpdaterFactory = _ => new TestProjectUpdater
            {
                UpdateProjectAsyncCallback = (_, _) => throw new InvalidOperationException("Project updates must not run if tool version resolution fails.")
            };
        });
        using var provider = services.BuildServiceProvider();
 
        var result = await provider.GetRequiredService<RootCommand>()
            .Parse("update --channel stable --yes --non-interactive").InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.FailedToUpgradeProject, result);
        Assert.Equal(originalDotNetManifest, await File.ReadAllTextAsync(dotnetManifest));
        Assert.Equal(originalNpmManifest, await File.ReadAllTextAsync(npmManifest));
        Assert.Contains(interaction.DisplayedErrors, message => message.Contains("@microsoft/aspire-cli@latest", StringComparison.Ordinal));
    }
 
    [Theory]
    [InlineData(true, true, 0)]
    [InlineData(true, true, 1)]
    [InlineData(true, false, 0)]
    [InlineData(false, true, 0)]
    [InlineData(false, false, 0)]
    public async Task Update_AppliesManifestAndProjectEditsTogetherBeforeRestore(bool confirmUpdates, bool projectNeedsUpdates, int restoreExitCode)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var directory = workspace.WorkspaceRoot;
        var (dotnetManifest, npmManifest) = await CreateManifestsAsync(directory);
        const string targetVersion = "13.6.0-pr.20295.test";
        var projectVersion = projectNeedsUpdates ? "13.4.0" : targetVersion;
        var appHostDirectory = workspace.CreateDirectory(Path.Combine("src", "AppHost"));
        var appHost = new FileInfo(Path.Combine(appHostDirectory.FullName, "AppHost.csproj"));
        await File.WriteAllTextAsync(appHost.FullName, $"""
            <Project Sdk="Aspire.AppHost.Sdk/{projectVersion}">
              <ItemGroup>
                <PackageReference Include="Aspire.Hosting.Redis" />
              </ItemGroup>
            </Project>
            """);
        var packagesPath = Path.Combine(directory.FullName, "Directory.Packages.props");
        await File.WriteAllTextAsync(packagesPath, $"""
            <Project>
              <PropertyGroup>
                <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
              </PropertyGroup>
              <ItemGroup>
                <PackageVersion Include="Aspire.Hosting.Redis" Version="{projectVersion}" />
                <PackageVersion Include="Unrelated.Package" Version="1.0.0" />
              </ItemGroup>
            </Project>
            """);
        var configPath = Path.Combine(directory.FullName, "aspire.config.json");
        await File.WriteAllTextAsync(configPath, $$"""
            {
              "appHost": { "path": "src/AppHost/AppHost.csproj" },
              "channel": "{{(projectNeedsUpdates ? "stable" : "daily")}}",
              "sdk": { "version": "{{projectVersion}}" }
            }
            """);
        var nugetPath = Path.Combine(directory.FullName, "NuGet.config");
        await File.WriteAllTextAsync(nugetPath, """
            <configuration>
              <packageSources>
                <clear />
                <add key="Existing" value="https://example.invalid/existing/v3/index.json" />
              </packageSources>
            </configuration>
            """);
        var lockPath = Path.Combine(directory.FullName, "package-lock.json");
        await File.WriteAllTextAsync(lockPath, """{"lockfileVersion":3}""");
        var originals = new Dictionary<string, byte[]>();
        foreach (var path in new[] { dotnetManifest, npmManifest, appHost.FullName, packagesPath, configPath, nugetPath, lockPath })
        {
            originals.Add(path, await File.ReadAllBytesAsync(path));
        }
 
        var restoreCalls = 0;
        var interaction = new TestInteractionService();
        interaction.ConfirmCallback = (prompt, _) =>
        {
            if (prompt != UpdateCommandStrings.PerformUpdatesPrompt)
            {
                Assert.Equal(UpdateCommandStrings.ApplyChangesToNuGetConfig, prompt);
                return false;
            }
 
            Assert.Contains(interaction.DisplayedMessages, message =>
                message.Message.Contains(RepositoryToolUpdater.DotNetPackageId, StringComparison.Ordinal) &&
                message.Message.Contains(RepositoryToolUpdater.NpmPackageId, StringComparison.Ordinal));
            if (projectNeedsUpdates)
            {
                Assert.Contains(interaction.DisplayedMessages, message => message.Message.Contains("Aspire.Hosting.Redis", StringComparison.Ordinal));
                Assert.Contains(interaction.DisplayedMessages, message => message.Message.Contains("aspire.config.json#sdk.version", StringComparison.Ordinal));
            }
            foreach (var (path, original) in originals)
            {
                Assert.Equal(original, File.ReadAllBytes(path));
            }
            return confirmUpdates;
        };
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            ConfigureUpdates(options, interaction);
            options.ProjectLocatorFactory = _ => new TestProjectLocator
            {
                UseOrFindAppHostProjectFileAsyncCallback = (_, _, _) => Task.FromResult<FileInfo?>(appHost)
            };
            options.PackagingServiceFactory = _ => new TestPackagingService
            {
                GetChannelsAsyncCallback = _ => Task.FromResult<IEnumerable<PackageChannel>>(
                [
                    new PackageChannel(PackageChannelNames.Daily, PackageChannelQuality.Both,
                        [new PackageMapping("Aspire*", "https://example.invalid/daily/v3/index.json")],
                        new FakeNuGetPackageCache(), new TestFeatures(), NullLogger.Instance,
                        pinnedVersion: targetVersion)
                ])
            };
            options.NpmRunnerFactory = _ => new FakeNpmRunner
            {
                ResolvePackageAsyncCallback = (_, _, _) => Task.FromResult<NpmPackageInfo?>(new() { Version = SemVersion.Parse(targetVersion) })
            };
            options.DotNetCliRunnerFactory = _ => new TestDotNetCliRunner
            {
                GetProjectItemsAndPropertiesAsyncCallback = (_, _, _, _, _) => (0, JsonDocument.Parse($$"""
                    {
                      "Properties": {
                        "AspireHostingSDKVersion": "{{projectVersion}}",
                        "ManagePackageVersionsCentrally": "true"
                      },
                      "Items": {
                        "PackageReference": [{ "Identity": "Aspire.Hosting.Redis", "Version": "{{projectVersion}}" }]
                      }
                    }
                    """)),
                GetNuGetConfigPathsAsyncCallback = (_, _, _) => (0, [nugetPath]),
                RestoreAsyncCallback = (_, _, _) =>
                {
                    restoreCalls++;
                    AssertFilesUpdated();
                    return restoreExitCode;
                }
            };
        });
        using var provider = services.BuildServiceProvider();
 
        var result = await provider.GetRequiredService<RootCommand>()
            .Parse("update --channel daily").InvokeAsync().DefaultTimeout();
 
        Assert.Equal(restoreExitCode == 0 ? CliExitCodes.Success : CliExitCodes.FailedToUpgradeProject, result);
        Assert.Single(interaction.BooleanPromptCalls, call => call.PromptText == UpdateCommandStrings.PerformUpdatesPrompt);
        Assert.Equal(confirmUpdates && projectNeedsUpdates ? 2 : 1, interaction.BooleanPromptCalls.Count);
        Assert.Equal(confirmUpdates && projectNeedsUpdates ? 1 : 0, restoreCalls);
        if (confirmUpdates)
        {
            AssertFilesUpdated();
            Assert.Contains(UpdateCommandStrings.RepositoryToolsUpdated, interaction.DisplayedSuccess);
        }
        else
        {
            foreach (var (path, original) in originals)
            {
                Assert.Equal(original, await File.ReadAllBytesAsync(path));
            }
        }
        Assert.Equal(originals[nugetPath], await File.ReadAllBytesAsync(nugetPath));
        Assert.Equal(originals[lockPath], await File.ReadAllBytesAsync(lockPath));
 
        void AssertFilesUpdated()
        {
            Assert.Equal(targetVersion, JsonNode.Parse(File.ReadAllText(dotnetManifest))!["tools"]!["aspire.cli"]!["version"]!.GetValue<string>());
            Assert.Equal("^" + targetVersion, JsonNode.Parse(File.ReadAllText(npmManifest))!["devDependencies"]![RepositoryToolUpdater.NpmPackageId]!.GetValue<string>());
            Assert.Equal("Aspire.AppHost.Sdk/" + targetVersion, XDocument.Load(appHost.FullName).Root!.Attribute("Sdk")!.Value);
            var packages = XDocument.Load(packagesPath).Descendants("PackageVersion").ToArray();
            Assert.Equal(targetVersion, packages.Single(package => package.Attribute("Include")!.Value == "Aspire.Hosting.Redis").Attribute("Version")!.Value);
            Assert.Equal("1.0.0", packages.Single(package => package.Attribute("Include")!.Value == "Unrelated.Package").Attribute("Version")!.Value);
            var config = JsonNode.Parse(File.ReadAllText(configPath))!;
            Assert.Equal(targetVersion, config["sdk"]!["version"]!.GetValue<string>());
            Assert.Equal("daily", config["channel"]!.GetValue<string>());
        }
    }
 
    private static void ConfigureUpdates(CliServiceCollectionTestOptions options, TestInteractionService interaction)
    {
        options.InteractionServiceFactory = _ => interaction;
        options.PackagingServiceFactory = _ => new TestPackagingService
        {
            GetChannelsAsyncCallback = _ => Task.FromResult<IEnumerable<PackageChannel>>(
            [
                new PackageChannel(PackageChannelNames.Stable, PackageChannelQuality.Stable,
                    [new PackageMapping("Aspire*", "https://api.nuget.org/v3/index.json")],
                    new FakeNuGetPackageCache
                    {
                        GetPackagesAsyncCallback = (_, packageId, _, _, _, _, _) =>
                            Task.FromResult<IEnumerable<Aspire.Shared.NuGetPackageCli>>(
                            [
                                new() { Id = packageId, Version = "13.5.4", Source = "https://api.nuget.org/v3/index.json" }
                            ])
                    }, new TestFeatures(), NullLogger.Instance, cliDownloadBaseUrl: "https://example.invalid/cli")
            ])
        };
        options.NpmRunnerFactory = _ => new FakeNpmRunner
        {
            ResolvePackageAsyncCallback = (_, _, _) => Task.FromResult<NpmPackageInfo?>(new() { Version = SemVersion.Parse("13.5.4") })
        };
        options.CliDownloaderFactory = sp => new TestCliDownloader(sp.GetRequiredService<CliExecutionContext>().WorkingDirectory)
        {
            DownloadLatestCliAsyncCallback = (_, _) => throw new InvalidOperationException("Repository updates must not replace the CLI executable.")
        };
        options.CliUpdateNotifierFactory = _ => new TestCliUpdateNotifier { IsUpdateAvailableCallback = () => true };
    }
 
    private static async Task<(string DotnetManifest, string NpmManifest)> CreateManifestsAsync(DirectoryInfo directory)
    {
        Directory.CreateDirectory(Path.Combine(directory.FullName, ".git"));
        var config = Directory.CreateDirectory(Path.Combine(directory.FullName, ".config"));
        var dotnetManifest = Path.Combine(config.FullName, "dotnet-tools.json");
        await File.WriteAllTextAsync(dotnetManifest, """
            {
              "version": 1,
              "isRoot": true,
              "tools": {
                "aspire.cli": { "version": "13.4.0", "commands": ["aspire"], "rollForward": true }
              }
            }
            """);
        var npmManifest = Path.Combine(directory.FullName, "package.json");
        await File.WriteAllTextAsync(npmManifest, """
            {
              "name": "test-app",
              "devDependencies": { "@microsoft/aspire-cli": "^13.4.0" }
            }
            """);
        return (dotnetManifest, npmManifest);
    }
}