File: Projects\RepositoryToolUpdaterTests.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;
using Aspire.Cli.Interaction;
using Aspire.Cli.Npm;
using Aspire.Cli.Packaging;
using Aspire.Cli.Projects;
using Aspire.Cli.Resources;
using Aspire.Cli.Tests.TestServices;
using Aspire.Shared;
using Microsoft.Extensions.Logging.Abstractions;
using Semver;
 
namespace Aspire.Cli.Tests.Projects;
 
public class RepositoryToolUpdaterTests(ITestOutputHelper outputHelper)
{
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public async Task FindManifestsAsync_FindsDotNetManifestInAncestor(bool useConfigDirectory)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var root = CreateRepository(workspace);
        var workingDirectory = root.CreateSubdirectory(Path.Combine("src", "app"));
        var manifestDirectory = useConfigDirectory ? root.CreateSubdirectory(".config") : root;
        var manifestPath = await WriteManifestAsync(manifestDirectory, isNpm: false, "13.3.0");
        var updater = CreateUpdater(CreateUnusedNpmRunner(), new TestInteractionService());
 
        var manifests = await updater.FindManifestsAsync(workingDirectory, CancellationToken.None);
 
        var manifest = Assert.Single(manifests);
        Assert.Equal(manifestPath, manifest.File.FullName);
        Assert.False(manifest.IsNpm);
        Assert.Equal("Aspire.Cli", manifest.PackageId);
        Assert.Equal("13.3.0", Assert.Single(manifest.References).Version);
    }
 
    [Fact]
    public async Task FindManifestsAsync_PrefersConfigDotNetManifestOverBareManifest()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var root = CreateRepository(workspace);
        var configPath = await WriteManifestAsync(root.CreateSubdirectory(".config"), isNpm: false, "13.3.0");
        await WriteManifestAsync(root, isNpm: false, "13.2.0");
        var updater = CreateUpdater(CreateUnusedNpmRunner(), new TestInteractionService());
 
        var manifests = await updater.FindManifestsAsync(root, CancellationToken.None);
 
        Assert.Equal(configPath, Assert.Single(manifests).File.FullName);
    }
 
    [Fact]
    public async Task FindManifestsAsync_SkipsUnrelatedNonRootDotNetManifest()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var root = CreateRepository(workspace);
        var child = root.CreateSubdirectory("src");
        var unrelatedPath = Path.Combine(child.CreateSubdirectory(".config").FullName, "dotnet-tools.json");
        const string unrelatedContent = """{"version":1,"isRoot":false,"tools":{"dotnet-ef":{"version":"10.0.0","commands":["dotnet-ef"]}}}""";
        await File.WriteAllTextAsync(unrelatedPath, unrelatedContent);
        var manifestPath = await WriteManifestAsync(root, isNpm: false, "13.3.0");
        var updater = CreateUpdater(CreateUnusedNpmRunner(), new TestInteractionService());
 
        var manifests = await updater.FindManifestsAsync(child, CancellationToken.None);
 
        Assert.Equal(manifestPath, Assert.Single(manifests).File.FullName);
        Assert.Equal(unrelatedContent, await File.ReadAllTextAsync(unrelatedPath));
    }
 
    [Fact]
    public async Task FindManifestsAsync_IsRootStopsDotNetLookupWithoutStoppingNpmLookup()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var root = CreateRepository(workspace);
        var child = root.CreateSubdirectory("src");
        await WriteManifestAsync(root, isNpm: false, "13.3.0");
        await WriteManifestAsync(child, isNpm: false, "13.2.0");
        var rootManifestPath = Path.Combine(child.CreateSubdirectory(".config").FullName, "dotnet-tools.json");
        const string rootManifest = """{"version":1,"isRoot":true,"tools":{"dotnet-ef":{"version":"10.0.0"}}}""";
        await File.WriteAllTextAsync(rootManifestPath, rootManifest);
        var npmPath = await WriteManifestAsync(root, isNpm: true, "13.3.0");
        var updater = CreateUpdater(CreateUnusedNpmRunner(), new TestInteractionService());
 
        var manifests = await updater.FindManifestsAsync(child, CancellationToken.None);
 
        var manifest = Assert.Single(manifests);
        Assert.True(manifest.IsNpm);
        Assert.Equal(npmPath, manifest.File.FullName);
        Assert.Equal(rootManifest, await File.ReadAllTextAsync(rootManifestPath));
    }
 
    [Theory]
    [InlineData(false, false)]
    [InlineData(false, true)]
    [InlineData(true, false)]
    [InlineData(true, true)]
    public async Task FindManifestsAsync_FindsNearestManifestsRegardlessOfGitDirectoryOrWorktree(bool gitFile, bool localManifests)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var ancestorDotnet = await WriteManifestAsync(workspace.WorkspaceRoot, isNpm: false, "13.1.0");
        var ancestorNpm = await WriteManifestAsync(workspace.WorkspaceRoot, isNpm: true, "13.1.0");
        var root = workspace.CreateDirectory("repository");
        var gitPath = Path.Combine(root.FullName, ".git");
        if (gitFile)
        {
            await File.WriteAllTextAsync(gitPath, "gitdir: ../worktrees/repository");
        }
        else
        {
            Directory.CreateDirectory(gitPath);
        }
 
        var expectedPaths = new List<string>();
        if (localManifests)
        {
            expectedPaths.Add(await WriteManifestAsync(root, isNpm: false, "13.3.0"));
            expectedPaths.Add(await WriteManifestAsync(root, isNpm: true, "13.3.0"));
        }
        else
        {
            expectedPaths.Add(ancestorDotnet);
            expectedPaths.Add(ancestorNpm);
        }
 
        var updater = CreateUpdater(CreateUnusedNpmRunner(), new TestInteractionService());
        var manifests = await updater.FindManifestsAsync(root.CreateSubdirectory("src"), CancellationToken.None);
 
        Assert.Equal(expectedPaths, manifests.Select(manifest => manifest.File.FullName));
    }
 
    [Fact]
    public async Task FindManifestsAsync_FindsNearestNpmManifestContainingCli()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var root = CreateRepository(workspace);
        await WriteManifestAsync(root, isNpm: true, "13.1.0");
        var nearestDirectory = root.CreateSubdirectory("packages");
        var nearestPath = await WriteManifestAsync(nearestDirectory, isNpm: true, "13.3.0");
        var child = nearestDirectory.CreateSubdirectory("app");
        var unrelatedPath = Path.Combine(child.FullName, "package.json");
        const string unrelatedContent = """{"name":"app","dependencies":{"typescript":"5.9.0"}}""";
        await File.WriteAllTextAsync(unrelatedPath, unrelatedContent);
        var updater = CreateUpdater(CreateUnusedNpmRunner(), new TestInteractionService());
 
        var manifests = await updater.FindManifestsAsync(child, CancellationToken.None);
 
        Assert.Equal(nearestPath, Assert.Single(manifests).File.FullName);
        Assert.Equal(unrelatedContent, await File.ReadAllTextAsync(unrelatedPath));
    }
 
    [Theory]
    [InlineData(false, false)]
    [InlineData(false, true)]
    [InlineData(true, false)]
    [InlineData(true, true)]
    public async Task UpdateAsync_UpdatesLinkedManifests(bool directoryLink, bool targetInRepository)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var root = workspace.CreateDirectory("repository");
        root.CreateSubdirectory(".git");
        var targetDirectory = targetInRepository
            ? root.CreateSubdirectory("manifests")
            : workspace.CreateDirectory("shared");
        var target = await WriteManifestAsync(targetDirectory, isNpm: !directoryLink, "13.3.0");
        var link = Path.Combine(root.FullName, directoryLink ? ".config" : "package.json");
        TestSymlinkHelper.TryCreateSymlink(link, directoryLink ? targetDirectory.FullName : target, directoryLink);
        var alias = Path.Combine(workspace.WorkspaceRoot.FullName, "alias");
        TestSymlinkHelper.TryCreateSymlink(alias, root.FullName);
        var npm = new FakeNpmRunner
        {
            ResolvePackageAsyncCallback = (_, _, _) => Task.FromResult<NpmPackageInfo?>(new() { Version = SemVersion.Parse("13.4.0") })
        };
        var updater = CreateUpdater(npm, new TestInteractionService());
        var manifests = await updater.FindManifestsAsync(new DirectoryInfo(alias), CancellationToken.None);
 
        var result = await updater.UpdateAsync(manifests, CreateChannel("13.4.0"), PromptBinding.CreateDefault(true), CancellationToken.None);
 
        Assert.Equal(RepositoryToolUpdateResult.Applied, result);
        var updated = Assert.Single(await updater.FindManifestsAsync(new DirectoryInfo(alias), CancellationToken.None));
        Assert.Equal("13.4.0", Assert.Single(updated.References).Version);
        Assert.Equal(await File.ReadAllTextAsync(target), await File.ReadAllTextAsync(updated.File.FullName));
        Assert.NotNull(directoryLink ? new DirectoryInfo(link).LinkTarget : new FileInfo(link).LinkTarget);
    }
 
    [Fact]
    public async Task UpdateAsync_UpdatesCaseInsensitiveDotNetCliAndPreservesOtherTools()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var root = CreateRepository(workspace);
        var configDirectory = root.CreateSubdirectory(".config");
        var manifestPath = Path.Combine(configDirectory.FullName, "dotnet-tools.json");
        await File.WriteAllTextAsync(manifestPath, """
            {
              "version": 1,
              "isRoot": true,
              "tools": {
                "aSpIrE.cLi": {
                  "version": "13.3.0",
                  "commands": ["aspire"],
                  "rollForward": true
                },
                "dotnet-ef": {
                  "version": "10.0.0",
                  "commands": ["dotnet-ef"]
                }
              },
              "custom": { "keep": "unchanged" }
            }
            """);
        var unrelatedPath = Path.Combine(root.FullName, "package.json");
        const string unrelatedContent = """{"name":"app","dependencies":{"typescript":"5.9.0"}}""";
        await File.WriteAllTextAsync(unrelatedPath, unrelatedContent);
        var workingDirectory = root.CreateSubdirectory(Path.Combine("src", "app"));
        var localManifestPath = Path.Combine(workingDirectory.CreateSubdirectory(".config").FullName, "dotnet-tools.json");
        const string localManifest = """{"version":1,"isRoot":false,"tools":{"dotnet-ef":{"version":"9.0.0"}}}""";
        await File.WriteAllTextAsync(localManifestPath, localManifest);
        var cache = new FakeNuGetPackageCache
        {
            GetPackagesAsyncCallback = (directory, package, _, _, _, _, _) =>
            {
                Assert.Equal(configDirectory.FullName, directory.FullName);
                Assert.Equal("Aspire.Cli", package);
                return Task.FromResult<IEnumerable<NuGetPackageCli>>(
                [
                    new() { Id = "aspire.cli", Version = "13.4.0", Source = "test-feed" },
                    new() { Id = "Unrelated.Tool", Version = "99.0.0", Source = "test-feed" }
                ]);
            }
        };
        var channel = PackageChannel.CreateImplicitChannel(cache, new TestFeatures(), NullLogger.Instance);
        var interaction = new TestInteractionService();
        var updater = CreateUpdater(CreateUnusedNpmRunner(), interaction);
        var manifests = await updater.FindManifestsAsync(workingDirectory, CancellationToken.None);
 
        var result = await updater.UpdateAsync(manifests, channel, PromptBinding.CreateDefault(true), CancellationToken.None);
 
        Assert.Equal(RepositoryToolUpdateResult.Applied, result);
        Assert.Equal(manifestPath, Assert.Single(manifests).File.FullName);
        await Verify(await File.ReadAllTextAsync(manifestPath), "json");
        Assert.Equal(unrelatedContent, await File.ReadAllTextAsync(unrelatedPath));
        Assert.Equal(localManifest, await File.ReadAllTextAsync(localManifestPath));
        Assert.Single(interaction.BooleanPromptCalls);
        Assert.Contains(interaction.DisplayedMessages, message => message.Message == UpdateCommandStrings.RestoreRepositoryDotNetTool);
    }
 
    [Fact]
    public async Task UpdateAsync_UsesNpmLatestPreservesDependencyPrefixesAndLeavesLocksUnchanged()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var root = CreateRepository(workspace);
        var manifestPath = Path.Combine(root.FullName, "package.json");
        await File.WriteAllTextAsync(manifestPath, """
            {
              "name": "app",
              "private": true,
              "scripts": { "start": "aspire run", "other": "echo untouched" },
              "dependencies": {
                "@microsoft/aspire-cli": "13.3.0",
                "other-package": "^2.0.0"
              },
              "devDependencies": {
                "@microsoft/aspire-cli": "^13.3.0",
                "typescript": "~5.9.0"
              },
              "optionalDependencies": {
                "@microsoft/aspire-cli": "~13.3.0"
              },
              "custom": { "keep": ["one", "two"] }
            }
            """);
        var originalLocks = await WriteLockFilesAsync(root);
        var resolutionCalls = 0;
        var npm = new FakeNpmRunner
        {
            ResolvePackageAsyncCallback = (package, version, _) =>
            {
                Assert.Equal("@microsoft/aspire-cli", package);
                Assert.Equal("latest", version);
                resolutionCalls++;
                return Task.FromResult<NpmPackageInfo?>(new() { Version = SemVersion.Parse("13.4.0") });
            }
        };
        var cache = new FakeNuGetPackageCache
        {
            GetPackagesAsyncCallback = (_, _, _, _, _, _, _) => throw new InvalidOperationException("Stable npm updates must not resolve NuGet versions.")
        };
        var channel = PackageChannel.CreateExplicitChannel("stable", PackageChannelQuality.Stable, [], cache, new TestFeatures(), NullLogger.Instance);
        var interaction = new TestInteractionService();
        var updater = CreateUpdater(npm, interaction);
        var manifests = await updater.FindManifestsAsync(root, CancellationToken.None);
 
        await updater.UpdateAsync(manifests, channel, PromptBinding.CreateDefault(true), CancellationToken.None);
 
        await Verify(await File.ReadAllTextAsync(manifestPath), "json");
        Assert.Equal(1, resolutionCalls);
        foreach (var (path, original) in originalLocks)
        {
            Assert.Equal(original, await File.ReadAllBytesAsync(path));
        }
        Assert.Single(interaction.BooleanPromptCalls);
        Assert.Contains(interaction.DisplayedMessages, message => message.Message == UpdateCommandStrings.RestoreRepositoryNpmTool);
    }
 
    [Theory]
    [InlineData(false, "13.4.0")]
    [InlineData(true, "13.4.0")]
    [InlineData(false, "14.0.0")]
    [InlineData(true, "14.0.0")]
    public async Task UpdateAsync_ImplicitChannelDoesNotDowngradeOrRewriteCurrentVersions(bool isNpm, string currentVersion)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var root = CreateRepository(workspace);
        var manifestPath = await WriteManifestAsync(root, isNpm, currentVersion);
        var originalContent = await File.ReadAllBytesAsync(manifestPath);
        var lockPath = Path.Combine(root.FullName, "package-lock.json");
        const string originalLock = "{ \"lockfileVersion\": 3 }\n";
        await File.WriteAllTextAsync(lockPath, originalLock);
        var npm = CreateUnusedNpmRunner();
        var resolutionCalls = 0;
        if (isNpm)
        {
            npm.IsAvailable = true;
            npm.ResolvePackageAsyncCallback = (package, version, _) =>
            {
                Assert.Equal("@microsoft/aspire-cli", package);
                Assert.Equal("latest", version);
                resolutionCalls++;
                return Task.FromResult<NpmPackageInfo?>(new() { Version = SemVersion.Parse("13.4.0") });
            };
        }
 
        var interaction = new TestInteractionService
        {
            ConfirmCallback = (_, _) => throw new InvalidOperationException("An unchanged manifest must not prompt.")
        };
        var updater = CreateUpdater(npm, interaction);
        var manifests = await updater.FindManifestsAsync(root, CancellationToken.None);
 
        var result = await updater.UpdateAsync(manifests, CreateChannel("13.4.0"), PromptBinding.CreateDefault(true), CancellationToken.None);
 
        Assert.Equal(RepositoryToolUpdateResult.NoChanges, result);
        Assert.Equal(originalContent, await File.ReadAllBytesAsync(manifestPath));
        Assert.Equal(originalLock, await File.ReadAllTextAsync(lockPath));
        Assert.Equal(isNpm ? 1 : 0, resolutionCalls);
        Assert.Empty(interaction.BooleanPromptCalls);
        Assert.Empty(interaction.DisplayedSuccess);
        Assert.Contains(interaction.DisplayedMessages, message => message.Message == UpdateCommandStrings.RepositoryToolsUpToDate);
    }
 
    [Theory]
    [InlineData(false, "13.3.0")]
    [InlineData(true, "13.3.0")]
    [InlineData(false, "13.5.0-preview.1")]
    [InlineData(true, "13.5.0-preview.1")]
    public async Task UpdateAsync_ExplicitChannelCanSwitchVersions(bool isNpm, string targetVersion)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var root = CreateRepository(workspace);
        var manifestPath = await WriteManifestAsync(root, isNpm, "13.4.0");
        var channelName = targetVersion.Contains("preview", StringComparison.Ordinal) ? "daily" : "stable";
        var npm = CreateUnusedNpmRunner();
        var resolutionCalls = 0;
        if (isNpm)
        {
            npm.IsAvailable = true;
            npm.ResolvePackageAsyncCallback = (package, version, _) =>
            {
                Assert.Equal("@microsoft/aspire-cli", package);
                Assert.Equal(channelName == "stable" ? "latest" : targetVersion, version);
                resolutionCalls++;
                return Task.FromResult<NpmPackageInfo?>(new() { Version = SemVersion.Parse(targetVersion) });
            };
        }
 
        var updater = CreateUpdater(npm, new TestInteractionService());
        var manifests = await updater.FindManifestsAsync(root, CancellationToken.None);
 
        await updater.UpdateAsync(manifests, CreateChannel(targetVersion, channelName), PromptBinding.CreateDefault(true), CancellationToken.None);
 
        await Verify(await File.ReadAllTextAsync(manifestPath), "json")
            .UseFileName($"{nameof(RepositoryToolUpdaterTests)}.{nameof(UpdateAsync_ExplicitChannelCanSwitchVersions)}.{(isNpm ? "npm" : "dotnet")}.{channelName}");
        Assert.Equal(isNpm ? 1 : 0, resolutionCalls);
        Assert.False(File.Exists(Path.Combine(root.FullName, "package-lock.json")));
        Assert.False(File.Exists(Path.Combine(root.FullName, "npm-shrinkwrap.json")));
    }
 
    [Fact]
    public async Task UpdateAsync_NonStableVersionMustExistOnNpmBeforeWritingAnyManifest()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var root = CreateRepository(workspace);
        var dotNetPath = await WriteManifestAsync(root, isNpm: false, "13.3.0");
        var npmPath = await WriteManifestAsync(root, isNpm: true, "13.3.0");
        var originalDotNet = await File.ReadAllBytesAsync(dotNetPath);
        var originalNpm = await File.ReadAllBytesAsync(npmPath);
        var originalLocks = await WriteLockFilesAsync(root);
        var npm = CreateUnusedNpmRunner();
        npm.IsAvailable = true;
        var resolutionCalls = 0;
        npm.ResolvePackageAsyncCallback = (package, version, _) =>
        {
            Assert.Equal("@microsoft/aspire-cli", package);
            Assert.Equal("13.5.0-preview.1", version);
            resolutionCalls++;
            return Task.FromResult<NpmPackageInfo?>(null);
        };
        var interaction = new TestInteractionService();
        var updater = CreateUpdater(npm, interaction);
        var manifests = await updater.FindManifestsAsync(root, CancellationToken.None);
 
        var exception = await Assert.ThrowsAsync<ProjectUpdaterException>(() =>
            updater.UpdateAsync(manifests, CreateChannel("13.5.0-preview.1", "daily"), PromptBinding.CreateDefault(true), CancellationToken.None));
 
        Assert.Contains("@microsoft/aspire-cli", exception.Message);
        Assert.Contains("13.5.0-preview.1", exception.Message);
        Assert.Equal(1, resolutionCalls);
        Assert.Equal(originalDotNet, await File.ReadAllBytesAsync(dotNetPath));
        Assert.Equal(originalNpm, await File.ReadAllBytesAsync(npmPath));
        foreach (var (path, original) in originalLocks)
        {
            Assert.Equal(original, await File.ReadAllBytesAsync(path));
        }
        Assert.Empty(interaction.BooleanPromptCalls);
        Assert.Empty(interaction.DisplayedSuccess);
    }
 
    [Fact]
    public async Task UpdateAsync_NpmUnavailableFailsBeforeWritingAnyManifest()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var root = CreateRepository(workspace);
        var dotNetPath = await WriteManifestAsync(root, isNpm: false, "13.3.0");
        var npmPath = await WriteManifestAsync(root, isNpm: true, "13.3.0");
        var originalDotNet = await File.ReadAllBytesAsync(dotNetPath);
        var originalNpm = await File.ReadAllBytesAsync(npmPath);
        var interaction = new TestInteractionService();
        var updater = CreateUpdater(CreateUnusedNpmRunner(), interaction);
        var manifests = await updater.FindManifestsAsync(root, CancellationToken.None);
 
        var exception = await Assert.ThrowsAsync<ProjectUpdaterException>(() =>
            updater.UpdateAsync(manifests, CreateChannel("13.4.0"), PromptBinding.CreateDefault(true), CancellationToken.None));
 
        Assert.Contains("npm", exception.Message, StringComparison.OrdinalIgnoreCase);
        Assert.Equal(originalDotNet, await File.ReadAllBytesAsync(dotNetPath));
        Assert.Equal(originalNpm, await File.ReadAllBytesAsync(npmPath));
        Assert.Empty(interaction.BooleanPromptCalls);
        Assert.Empty(interaction.DisplayedSuccess);
    }
 
    [Fact]
    public async Task UpdateAsync_DecliningDoesNotWriteManifestsOrLocks()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var root = CreateRepository(workspace);
        var dotNetPath = await WriteManifestAsync(root, isNpm: false, "13.3.0");
        var npmPath = await WriteManifestAsync(root, isNpm: true, "^13.3.0");
        var originalDotNet = await File.ReadAllBytesAsync(dotNetPath);
        var originalNpm = await File.ReadAllBytesAsync(npmPath);
        var lockPath = Path.Combine(root.FullName, "package-lock.json");
        const string originalLock = "{ \"lockfileVersion\": 3 }\r\n";
        await File.WriteAllTextAsync(lockPath, originalLock);
        var npm = CreateUnusedNpmRunner();
        npm.IsAvailable = true;
        npm.ResolvePackageAsyncCallback = (_, _, _) => Task.FromResult<NpmPackageInfo?>(new() { Version = SemVersion.Parse("13.4.0") });
        var interaction = new TestInteractionService { ConfirmCallback = (_, _) => false };
        var updater = CreateUpdater(npm, interaction);
        var manifests = await updater.FindManifestsAsync(root, CancellationToken.None);
 
        var result = await updater.UpdateAsync(manifests, CreateChannel("13.4.0"), PromptBinding.CreateDefault(true), CancellationToken.None);
 
        Assert.Equal(RepositoryToolUpdateResult.Declined, result);
        Assert.Equal(originalDotNet, await File.ReadAllBytesAsync(dotNetPath));
        Assert.Equal(originalNpm, await File.ReadAllBytesAsync(npmPath));
        Assert.Equal(originalLock, await File.ReadAllTextAsync(lockPath));
        Assert.Single(interaction.BooleanPromptCalls);
        Assert.Empty(interaction.DisplayedSuccess);
    }
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public async Task UpdateAsync_CancellationDoesNotWriteManifestsOrLocks(bool cancelAtConfirmation)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var root = CreateRepository(workspace);
        var dotNetPath = await WriteManifestAsync(root, isNpm: false, "13.3.0");
        var npmPath = await WriteManifestAsync(root, isNpm: true, "~13.3.0");
        var encoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: true);
        await File.WriteAllTextAsync(dotNetPath, await File.ReadAllTextAsync(dotNetPath) + "\r\n", encoding);
        await File.WriteAllTextAsync(npmPath, await File.ReadAllTextAsync(npmPath) + "\r\n", encoding);
        var originalFiles = await WriteLockFilesAsync(root);
        foreach (var path in new[] { dotNetPath, npmPath })
        {
            originalFiles.Add(path, await File.ReadAllBytesAsync(path));
        }
 
        using var cancellation = new CancellationTokenSource();
        var resolutionCalls = 0;
        var npm = new FakeNpmRunner
        {
            ResolvePackageAsyncCallback = (_, _, cancellationToken) =>
            {
                Assert.Equal(cancellation.Token, cancellationToken);
                resolutionCalls++;
                if (!cancelAtConfirmation)
                {
                    cancellation.Cancel();
                    cancellationToken.ThrowIfCancellationRequested();
                }
 
                return Task.FromResult<NpmPackageInfo?>(new() { Version = SemVersion.Parse("13.4.0") });
            }
        };
        var interaction = new TestInteractionService
        {
            ConfirmCallback = (_, _) =>
            {
                Assert.True(cancelAtConfirmation);
                cancellation.Cancel();
                return true;
            }
        };
        var updater = CreateUpdater(npm, interaction);
        var manifests = await updater.FindManifestsAsync(root, cancellation.Token);
        var channel = CreateChannel("13.4.0");
 
        await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
            updater.UpdateAsync(manifests, channel, PromptBinding.CreateDefault(true), cancellation.Token));
 
        Assert.Equal(1, resolutionCalls);
        Assert.Equal(cancelAtConfirmation ? 1 : 0, interaction.BooleanPromptCalls.Count);
        foreach (var (path, original) in originalFiles)
        {
            Assert.Equal(original, await File.ReadAllBytesAsync(path));
        }
        Assert.Empty(interaction.DisplayedSuccess);
    }
 
    [Theory]
    [InlineData(false, """{"tools":{"Aspire.Cli":""")]
    [InlineData(true, """{"dependencies":{"@microsoft/aspire-cli":""")]
    [InlineData(false, """{"tools":{"Aspire.Cli":{"version":42}}}""")]
    [InlineData(false, """{"tools":{"Aspire.Cli":{}}}""")]
    [InlineData(false, """{"isRoot":"invalid","tools":{}}""")]
    [InlineData(true, """{"dependencies":{"@microsoft/aspire-cli":42}}""")]
    public async Task FindManifestsAsync_MalformedTargetedJsonReportsManifestPath(bool isNpm, string content)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var root = CreateRepository(workspace);
        var path = Path.Combine(root.FullName, isNpm ? "package.json" : "dotnet-tools.json");
        await File.WriteAllTextAsync(path, content);
        var updater = CreateUpdater(CreateUnusedNpmRunner(), new TestInteractionService());
 
        var exception = await Assert.ThrowsAsync<ProjectUpdaterException>(() =>
            updater.FindManifestsAsync(root, CancellationToken.None));
 
        Assert.Contains(path, exception.Message);
        Assert.Contains("Failed to read tool manifest", exception.Message);
        Assert.Equal(content, await File.ReadAllTextAsync(path));
    }
 
    [Theory]
    [InlineData("https://example.test/aspire-cli.tgz")]
    [InlineData("workspace:*")]
    [InlineData(">=13.3.0 <14.0.0")]
    [InlineData("file:../aspire-cli")]
    [InlineData("npm:another-cli@13.3.0")]
    public async Task UpdateAsync_UnsupportedNpmVersionsWarnWithoutChangingFiles(string version)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var root = CreateRepository(workspace);
        var path = await WriteManifestAsync(root, isNpm: true, version);
        var originalContent = await File.ReadAllBytesAsync(path);
        var lockPath = Path.Combine(root.FullName, "package-lock.json");
        await File.WriteAllTextAsync(lockPath, "{}");
        var interaction = new TestInteractionService();
        var updater = CreateUpdater(CreateUnusedNpmRunner(), interaction);
        var manifests = await updater.FindManifestsAsync(root, CancellationToken.None);
 
        await updater.UpdateAsync(manifests, CreateChannel("13.4.0"), PromptBinding.CreateDefault(true), CancellationToken.None);
 
        Assert.Equal(originalContent, await File.ReadAllBytesAsync(path));
        Assert.Equal("{}", await File.ReadAllTextAsync(lockPath));
        var warning = Assert.Single(interaction.DisplayedMessages);
        Assert.Equal(KnownEmojis.Warning, warning.Emoji);
        Assert.Contains(path, warning.Message);
        Assert.Contains(version, warning.Message);
        Assert.Empty(interaction.BooleanPromptCalls);
        Assert.Empty(interaction.DisplayedSuccess);
    }
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public async Task UpdateAsync_DoesNotCreateMissingManifestsOrCliEntries(bool unrelatedManifests)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var root = CreateRepository(workspace);
        var originalFiles = new Dictionary<string, byte[]>();
        foreach (var path in Directory.EnumerateFiles(root.FullName, "*", SearchOption.AllDirectories))
        {
            originalFiles.Add(path, await File.ReadAllBytesAsync(path));
        }
 
        if (unrelatedManifests)
        {
            var dotNetPath = Path.Combine(root.FullName, "dotnet-tools.json");
            var npmPath = Path.Combine(root.FullName, "package.json");
            await File.WriteAllTextAsync(dotNetPath, """{"version":1,"isRoot":true,"tools":{"dotnet-ef":{"version":"10.0.0"}}}""");
            await File.WriteAllTextAsync(npmPath, """{"name":"app","devDependencies":{"typescript":"5.9.0"}}""");
            originalFiles.Add(dotNetPath, await File.ReadAllBytesAsync(dotNetPath));
            originalFiles.Add(npmPath, await File.ReadAllBytesAsync(npmPath));
        }
 
        var interaction = new TestInteractionService();
        var updater = CreateUpdater(CreateUnusedNpmRunner(), interaction);
        var manifests = await updater.FindManifestsAsync(root, CancellationToken.None);
        var cache = new FakeNuGetPackageCache
        {
            GetPackagesAsyncCallback = (_, _, _, _, _, _, _) => throw new InvalidOperationException("An absent CLI reference must not resolve NuGet packages.")
        };
        var channel = PackageChannel.CreateImplicitChannel(cache, new TestFeatures(), NullLogger.Instance);
 
        await updater.UpdateAsync(manifests, channel, PromptBinding.CreateDefault(true), CancellationToken.None);
 
        Assert.Empty(manifests);
        Assert.Equal(originalFiles.Keys.Order(), Directory.EnumerateFiles(root.FullName, "*", SearchOption.AllDirectories).Order());
        foreach (var (path, original) in originalFiles)
        {
            Assert.Equal(original, await File.ReadAllBytesAsync(path));
        }
        Assert.Empty(interaction.BooleanPromptCalls);
        Assert.Empty(interaction.DisplayedSuccess);
    }
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public async Task GetUpdateStepAsync_DefersManifestWritesUntilCallback(bool cancelBeforeApply)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var root = CreateRepository(workspace);
        var dotnetPath = await WriteManifestAsync(root, isNpm: false, "13.3.0");
        var npmPath = await WriteManifestAsync(root, isNpm: true, "13.3.0");
        var originals = new Dictionary<string, byte[]>
        {
            [dotnetPath] = await File.ReadAllBytesAsync(dotnetPath),
            [npmPath] = await File.ReadAllBytesAsync(npmPath)
        };
        var npm = new FakeNpmRunner
        {
            ResolvePackageAsyncCallback = (_, _, _) => Task.FromResult<NpmPackageInfo?>(new() { Version = SemVersion.Parse("13.4.0") })
        };
        var interaction = new TestInteractionService();
        var updater = CreateUpdater(npm, interaction);
        using var cancellation = new CancellationTokenSource();
        var manifests = await updater.FindManifestsAsync(root, cancellation.Token);
 
        var updateStep = await updater.GetUpdateStepAsync(manifests, CreateChannel("13.4.0"), cancellation.Token);
 
        Assert.NotNull(updateStep);
        Assert.Empty(interaction.BooleanPromptCalls);
        Assert.Empty(interaction.DisplayedSuccess);
        foreach (var (path, original) in originals)
        {
            Assert.Equal(original, await File.ReadAllBytesAsync(path));
        }
 
        if (cancelBeforeApply)
        {
            cancellation.Cancel();
            await Assert.ThrowsAnyAsync<OperationCanceledException>(updateStep.Callback);
            foreach (var (path, original) in originals)
            {
                Assert.Equal(original, await File.ReadAllBytesAsync(path));
            }
            Assert.Empty(interaction.DisplayedSuccess);
        }
        else
        {
            await updateStep.Callback();
            var updatedManifests = await updater.FindManifestsAsync(root, cancellation.Token);
            Assert.Equal(2, updatedManifests.Count);
            Assert.All(updatedManifests, manifest => Assert.Equal("13.4.0", Assert.Single(manifest.References).Version));
            Assert.Equal(UpdateCommandStrings.RepositoryToolsUpdated, Assert.Single(interaction.DisplayedSuccess));
        }
        Assert.Empty(interaction.BooleanPromptCalls);
    }
 
    [Theory]
    [InlineData("""{"dependencies":{"@microsoft/aspire-cli":"13.2.0"}}""")]
    [InlineData("""{"dependencies":{}}""")]
    [InlineData("""{"dependencies":{"@microsoft/aspire-cli":"13.3.0"},"devDependencies":{"@microsoft/aspire-cli":"13.3.0"}}""")]
    [InlineData("""{"devDependencies":{"@microsoft/aspire-cli":"13.3.0"}}""")]
    public async Task GetUpdateStepAsync_ChangedCliReferencesDoNotApplyAnyManifest(string changedManifest)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var root = CreateRepository(workspace);
        var dotnetPath = await WriteManifestAsync(root, isNpm: false, "13.3.0");
        var npmPath = await WriteManifestAsync(root, isNpm: true, "13.3.0");
        var originalDotnet = await File.ReadAllBytesAsync(dotnetPath);
        var npm = new FakeNpmRunner
        {
            ResolvePackageAsyncCallback = (_, _, _) => Task.FromResult<NpmPackageInfo?>(new() { Version = SemVersion.Parse("13.4.0") })
        };
        var interaction = new TestInteractionService();
        var updater = CreateUpdater(npm, interaction);
        var manifests = await updater.FindManifestsAsync(root, CancellationToken.None);
        var updateStep = await updater.GetUpdateStepAsync(manifests, CreateChannel("13.4.0"), CancellationToken.None);
        Assert.NotNull(updateStep);
        await File.WriteAllTextAsync(npmPath, changedManifest);
 
        var exception = await Assert.ThrowsAsync<ProjectUpdaterException>(updateStep.Callback);
 
        Assert.Equal(string.Format(UpdateCommandStrings.ToolManifestChangedFormat, npmPath), exception.Message);
        Assert.Equal(originalDotnet, await File.ReadAllBytesAsync(dotnetPath));
        Assert.Equal(changedManifest, await File.ReadAllTextAsync(npmPath));
        Assert.Empty(interaction.DisplayedSuccess);
    }
 
    private static DirectoryInfo CreateRepository(TemporaryWorkspace workspace)
    {
        workspace.CreateDirectory(".git");
        return workspace.WorkspaceRoot;
    }
 
    private static async Task<string> WriteManifestAsync(DirectoryInfo directory, bool isNpm, string version)
    {
        var path = Path.Combine(directory.FullName, isNpm ? "package.json" : "dotnet-tools.json");
        var content = isNpm
            ? $$"""
                {
                  "name": "app",
                  "dependencies": {
                    "@microsoft/aspire-cli": "{{version}}"
                  }
                }
                """
            : $$"""
                {
                  "version": 1,
                  "isRoot": true,
                  "tools": {
                    "Aspire.Cli": {
                      "version": "{{version}}",
                      "commands": ["aspire"]
                    }
                  }
                }
                """;
        await File.WriteAllTextAsync(path, content);
        return path;
    }
 
    private static RepositoryToolUpdater CreateUpdater(FakeNpmRunner npmRunner, TestInteractionService interaction)
        => new(npmRunner, interaction, NullLogger<RepositoryToolUpdater>.Instance);
 
    private static async Task<Dictionary<string, byte[]>> WriteLockFilesAsync(DirectoryInfo directory)
    {
        var packageLockPath = Path.Combine(directory.FullName, "package-lock.json");
        var shrinkwrapPath = Path.Combine(directory.FullName, "npm-shrinkwrap.json");
        await File.WriteAllTextAsync(packageLockPath, "{\r\n  \"lockfileVersion\" : 3\r\n}\r\n", new UTF8Encoding(encoderShouldEmitUTF8Identifier: true));
        await File.WriteAllTextAsync(shrinkwrapPath, "{ \"name\" : \"original\" }\n");
        return new Dictionary<string, byte[]>
        {
            [packageLockPath] = await File.ReadAllBytesAsync(packageLockPath),
            [shrinkwrapPath] = await File.ReadAllBytesAsync(shrinkwrapPath)
        };
    }
 
    private static FakeNpmRunner CreateUnusedNpmRunner()
        => new()
        {
            IsAvailable = false,
            ResolvePackageAsyncCallback = (_, _, _) => throw new InvalidOperationException("npm resolution must not run.")
        };
 
    private static PackageChannel CreateChannel(string version, string? name = null)
    {
        var cache = new FakeNuGetPackageCache
        {
            GetPackagesAsyncCallback = (_, package, _, _, _, _, _) =>
            {
                Assert.Equal("Aspire.Cli", package);
                return Task.FromResult<IEnumerable<NuGetPackageCli>>(
                [
                    new() { Id = "aspire.cli", Version = version, Source = "test-feed" },
                    new() { Id = "Unrelated.Tool", Version = "99.0.0", Source = "test-feed" }
                ]);
            }
        };
        return name is null
            ? PackageChannel.CreateImplicitChannel(cache, new TestFeatures(), NullLogger.Instance)
            : PackageChannel.CreateExplicitChannel(name, PackageChannelQuality.Both, [], cache, new TestFeatures(), NullLogger.Instance);
    }
}