File: Utils\CliUpdateNotificationServiceTests.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 Aspire.Cli.Interaction;
using Aspire.Cli.NuGet;
using Aspire.Cli.Tests.TestServices;
using Aspire.Cli.Utils;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Semver;
using NuGetPackage = Aspire.Shared.NuGetPackageCli;
using Microsoft.AspNetCore.InternalTesting;
 
namespace Aspire.Cli.Tests.Utils;
 
public class CliUpdateNotificationServiceTests(ITestOutputHelper outputHelper)
{
    [Fact]
    public async Task PrereleaseWillRecommendUpgradeToPrereleaseOnSameVersionFamily()
    {
        var currentVersion = VersionHelper.GetDefaultTemplateVersion();
        TaskCompletionSource<string> suggestedVersionTcs = new();
 
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, configure =>
        {
            configure.NuGetPackageCacheFactory = (sp) =>
            {
                var cache = new FakeNuGetPackageCache { GetCliPackagesAsyncCallback = (_, _, _, _) => Task.FromResult<IEnumerable<NuGetPackage>>([
                    // Should be ignored because it's lower than current prerelease version.
                    new NuGetPackage { Id = "Aspire.Cli", Version = "9.3.1", Source = "nuget.org" },
 
                    // Should be selected because it is higher than 9.4.0-dev (dev and preview sort using alphabetical sort).
                    new NuGetPackage { Id = "Aspire.Cli", Version = "9.4.0-preview", Source = "nuget.org" }, 
 
                    // Should be ignored because it is lower than 9.4.0-dev (dev and preview sort using alpha).
                    new NuGetPackage { Id = "Aspire.Cli", Version = "9.4.0-beta", Source = "nuget.org" }
                ]) }; return cache;
            };
 
            configure.InteractionServiceFactory = (sp) =>
            {
                var interactionService = new TestInteractionService();
                interactionService.DisplayVersionUpdateNotificationCallback = (newerVersion) =>
                {
                    suggestedVersionTcs.SetResult(newerVersion);
                };
 
                return interactionService;
            };
 
            configure.CliUpdateNotifierFactory = sp => CreateCliUpdateNotifier(sp, "9.4.0-dev");
        });
 
        using var provider = services.BuildServiceProvider();
        var notifier = provider.GetRequiredService<ICliUpdateNotifier>();
 
        await notifier.CheckForCliUpdatesAsync(workspace.WorkspaceRoot, CancellationToken.None).DefaultTimeout();
        notifier.NotifyIfUpdateAvailable();
        var suggestedVersion = await suggestedVersionTcs.Task.DefaultTimeout();
 
        Assert.Equal("9.4.0-preview", suggestedVersion);
    }
 
    [Fact]
    public async Task PrereleaseWillRecommendUpgradeToStableInCurrentVersionFamily()
    {
        var currentVersion = VersionHelper.GetDefaultTemplateVersion();
        TaskCompletionSource<string> suggestedVersionTcs = new();
 
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, configure =>
        {
            configure.NuGetPackageCacheFactory = (sp) =>
            {
                var cache = new FakeNuGetPackageCache { GetCliPackagesAsyncCallback = (_, _, _, _) => Task.FromResult<IEnumerable<NuGetPackage>>([
                    // Should be selected because stable sorts higher than preview.
                    new NuGetPackage { Id = "Aspire.Cli", Version = "9.4.0", Source = "nuget.org" },
 
                    // Should be ignored because its prerelease but in a higher version family.
                    new NuGetPackage { Id = "Aspire.Cli", Version = "9.5.0-preview", Source = "nuget.org" },
                ]) }; return cache;
            };
 
            configure.InteractionServiceFactory = (sp) =>
            {
                var interactionService = new TestInteractionService();
                interactionService.DisplayVersionUpdateNotificationCallback = (newerVersion) =>
                {
                    suggestedVersionTcs.SetResult(newerVersion);
                };
 
                return interactionService;
            };
 
            configure.CliUpdateNotifierFactory = sp => CreateCliUpdateNotifier(sp, "9.4.0-dev");
        });
 
        using var provider = services.BuildServiceProvider();
        var notifier = provider.GetRequiredService<ICliUpdateNotifier>();
 
        await notifier.CheckForCliUpdatesAsync(workspace.WorkspaceRoot, CancellationToken.None).DefaultTimeout();
        notifier.NotifyIfUpdateAvailable();
        var suggestedVersion = await suggestedVersionTcs.Task.DefaultTimeout();
 
        Assert.Equal("9.4.0", suggestedVersion);
    }
 
    [Fact]
    public async Task StableWillOnlyRecommendGoingToNewerStable()
    {
        var currentVersion = VersionHelper.GetDefaultTemplateVersion();
        TaskCompletionSource<string> suggestedVersionTcs = new();
 
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, configure =>
        {
            configure.NuGetPackageCacheFactory = (sp) =>
            {
                var cache = new FakeNuGetPackageCache { GetCliPackagesAsyncCallback = (_, _, _, _) => Task.FromResult<IEnumerable<NuGetPackage>>([
                    // Should be ignored because its stable in a higher version family.
                    new NuGetPackage { Id = "Aspire.Cli", Version = "9.5.0", Source = "nuget.org" }, 
 
                    // Should be ignored because its prerelease but in a (even) higher version family.
                    new NuGetPackage { Id = "Aspire.Cli", Version = "9.6.0-preview", Source = "nuget.org" },
                ]) }; return cache;
            };
 
            configure.InteractionServiceFactory = (sp) =>
            {
                var interactionService = new TestInteractionService();
                interactionService.DisplayVersionUpdateNotificationCallback = (newerVersion) =>
                {
                    suggestedVersionTcs.SetResult(newerVersion);
                };
 
                return interactionService;
            };
 
            configure.CliUpdateNotifierFactory = sp => CreateCliUpdateNotifier(sp, "9.4.0");
        });
 
        using var provider = services.BuildServiceProvider();
        var notifier = provider.GetRequiredService<ICliUpdateNotifier>();
 
        await notifier.CheckForCliUpdatesAsync(workspace.WorkspaceRoot, CancellationToken.None).DefaultTimeout();
        notifier.NotifyIfUpdateAvailable();
        var suggestedVersion = await suggestedVersionTcs.Task.DefaultTimeout();
 
        Assert.Equal("9.5.0", suggestedVersion);
    }
 
    [Fact]
    public void NotifyIfUpdateAvailable_WithoutCachedPackages_DoesNotNotify()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, configure =>
        {
            configure.InteractionServiceFactory = sp =>
            {
                var interactionService = new TestInteractionService();
                interactionService.DisplayVersionUpdateNotificationCallback = _ =>
                {
                    Assert.Fail("Should not notify before package metadata has been cached.");
                };
 
                return interactionService;
            };
 
            configure.CliUpdateNotifierFactory = sp => CreateCliUpdateNotifier(sp, "9.4.0");
        });
 
        using var provider = services.BuildServiceProvider();
        var notifier = provider.GetRequiredService<ICliUpdateNotifier>();
 
        notifier.NotifyIfUpdateAvailable();
    }
 
    [Fact]
    public async Task NotifyIfUpdateAvailable_UsesDotnetToolCommandForNativeAotToolStorePath()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        TestInteractionService? interactionService = null;
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, configure =>
        {
            UseProcessPath(configure, "/home/test/.dotnet/tools/.store/aspire.cli/9.4.0/aspire.cli.linux-x64/9.4.0/tools/any/linux-x64/aspire");
 
            configure.NuGetPackageCacheFactory = _ => new FakeNuGetPackageCache
            {
                GetCliPackagesAsyncCallback = (_, _, _, _) => Task.FromResult<IEnumerable<NuGetPackage>>([
                    new NuGetPackage { Id = "Aspire.Cli", Version = "9.5.0", Source = "nuget.org" }
                ])
            };
 
            configure.InteractionServiceFactory = _ =>
            {
                interactionService = new TestInteractionService();
                return interactionService;
            };
 
            configure.CliUpdateNotifierFactory = sp => CreateCliUpdateNotifier(sp, "9.4.0");
        });
 
        using var provider = services.BuildServiceProvider();
        var notifier = provider.GetRequiredService<ICliUpdateNotifier>();
 
        await notifier.CheckForCliUpdatesAsync(workspace.WorkspaceRoot, CancellationToken.None).DefaultTimeout();
        notifier.NotifyIfUpdateAvailable();
 
        Assert.NotNull(interactionService);
        Assert.Equal("dotnet tool update -g Aspire.Cli", interactionService.LastVersionUpdateCommand);
    }
 
    [Fact]
    public async Task NotifyIfUpdateAvailable_UsesToolPathCommandForCustomToolPath()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var toolPath = Path.Combine(workspace.CreateDirectory("install").FullName, "custom tool path");
        var processPath = CreateCustomToolPathInstall(toolPath);
        TestInteractionService? interactionService = null;
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, configure =>
        {
            UseProcessPath(configure, processPath);
 
            configure.NuGetPackageCacheFactory = _ => new FakeNuGetPackageCache
            {
                GetCliPackagesAsyncCallback = (_, _, _, _) => Task.FromResult<IEnumerable<NuGetPackage>>([
                    new NuGetPackage { Id = "Aspire.Cli", Version = "9.5.0", Source = "nuget.org" }
                ])
            };
 
            configure.InteractionServiceFactory = _ =>
            {
                interactionService = new TestInteractionService();
                return interactionService;
            };
 
            configure.CliUpdateNotifierFactory = sp => CreateCliUpdateNotifier(sp, "9.4.0");
        });
 
        using var provider = services.BuildServiceProvider();
        var notifier = provider.GetRequiredService<ICliUpdateNotifier>();
 
        await notifier.CheckForCliUpdatesAsync(workspace.WorkspaceRoot, CancellationToken.None).DefaultTimeout();
        notifier.NotifyIfUpdateAvailable();
 
        Assert.NotNull(interactionService);
        Assert.Equal($"dotnet tool update --tool-path \"{toolPath}\" Aspire.Cli", interactionService.LastVersionUpdateCommand);
    }
 
    [Fact]
    public async Task NotifyIfUpdateAvailable_UsesAspireUpdateCommandForStandaloneArchivePath()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        TestInteractionService? interactionService = null;
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, configure =>
        {
            UseProcessPath(configure, "/home/test/.aspire/bin/aspire");
 
            configure.NuGetPackageCacheFactory = _ => new FakeNuGetPackageCache
            {
                GetCliPackagesAsyncCallback = (_, _, _, _) => Task.FromResult<IEnumerable<NuGetPackage>>([
                    new NuGetPackage { Id = "Aspire.Cli", Version = "9.5.0", Source = "nuget.org" }
                ])
            };
 
            configure.InteractionServiceFactory = _ =>
            {
                interactionService = new TestInteractionService();
                return interactionService;
            };
 
            configure.CliUpdateNotifierFactory = sp => CreateCliUpdateNotifier(sp, "9.4.0");
        });
 
        using var provider = services.BuildServiceProvider();
        var notifier = provider.GetRequiredService<ICliUpdateNotifier>();
 
        await notifier.CheckForCliUpdatesAsync(workspace.WorkspaceRoot, CancellationToken.None).DefaultTimeout();
        notifier.NotifyIfUpdateAvailable();
 
        Assert.NotNull(interactionService);
        Assert.Equal("aspire update", interactionService.LastVersionUpdateCommand);
    }
 
    [Fact]
    public async Task NotifyIfUpdateAvailable_UsesNpmCommandForNpmInstall()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        using var npmScope = NpmInstallDetection.UseEnvironmentForTesting(CreateNpmInstallEnvironment());
        TestInteractionService? interactionService = null;
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, configure =>
        {
            UseProcessPath(configure, "/home/test/.aspire/bin/aspire");
 
            configure.NuGetPackageCacheFactory = _ => new FakeNuGetPackageCache
            {
                GetCliPackagesAsyncCallback = (_, _, _, _) => Task.FromResult<IEnumerable<NuGetPackage>>([
                    new NuGetPackage { Id = "Aspire.Cli", Version = "9.5.0", Source = "nuget.org" }
                ])
            };
 
            configure.InteractionServiceFactory = _ =>
            {
                interactionService = new TestInteractionService();
                return interactionService;
            };
 
            configure.CliUpdateNotifierFactory = sp => CreateCliUpdateNotifier(sp, "9.4.0");
        });
 
        using var provider = services.BuildServiceProvider();
        var notifier = provider.GetRequiredService<ICliUpdateNotifier>();
 
        await notifier.CheckForCliUpdatesAsync(workspace.WorkspaceRoot, CancellationToken.None).DefaultTimeout();
        notifier.NotifyIfUpdateAvailable();
 
        Assert.NotNull(interactionService);
        Assert.Equal("npm install -g @microsoft/aspire-cli@latest", interactionService.LastVersionUpdateCommand);
    }
 
    [Fact]
    public async Task StableWillNotRecommendUpdatingToPreview()
    {
        var currentVersion = VersionHelper.GetDefaultTemplateVersion();
 
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, configure =>
        {
            configure.NuGetPackageCacheFactory = (sp) =>
            {
                var cache = new FakeNuGetPackageCache { GetCliPackagesAsyncCallback = (_, _, _, _) => Task.FromResult<IEnumerable<NuGetPackage>>([
                    new NuGetPackage { Id = "Aspire.Cli", Version = "9.4.0-preview", Source = "nuget.org" },
                    new NuGetPackage { Id = "Aspire.Cli", Version = "9.5.0-preview", Source = "nuget.org" },
                ]) }; return cache;
            };
 
            configure.InteractionServiceFactory = (sp) =>
            {
                var interactionService = new TestInteractionService();
                interactionService.DisplayVersionUpdateNotificationCallback = (newerVersion) =>
                {
                    Assert.Fail("Should not suggest a preview version when current version is stable.");
                };
 
                return interactionService;
            };
 
            configure.CliUpdateNotifierFactory = sp => CreateCliUpdateNotifier(sp, "9.4.0");
        });
 
        using var provider = services.BuildServiceProvider();
        var notifier = provider.GetRequiredService<ICliUpdateNotifier>();
 
        await notifier.CheckForCliUpdatesAsync(workspace.WorkspaceRoot, CancellationToken.None).DefaultTimeout();
        notifier.NotifyIfUpdateAvailable();
    }
 
    [Fact]
    public async Task NotifyIfUpdateAvailableAsync_WithNewerStableVersion_DoesNotThrow()
    {
        // Arrange
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
 
        // Replace the NuGetPackageCache with our test implementation
        var nugetCache = new FakeNuGetPackageCache
        {
            GetCliPackagesAsyncCallback = (_, _, _, _) => Task.FromResult<IEnumerable<NuGetPackage>>([
                new NuGetPackage { Id = "Aspire.Cli", Version = "9.0.0", Source = "nuget.org" }
            ])
        };
        services.AddSingleton<INuGetPackageCache>(nugetCache);
        services.AddSingleton<ICliUpdateNotifier, CliUpdateNotifier>();
 
        using var provider = services.BuildServiceProvider();
        var service = provider.GetRequiredService<ICliUpdateNotifier>();
 
        // Act & Assert (should not throw)
        await service.CheckForCliUpdatesAsync(workspace.WorkspaceRoot, CancellationToken.None).DefaultTimeout();
        service.NotifyIfUpdateAvailable();
    }
 
    [Fact]
    public async Task NotifyIfUpdateAvailableAsync_WithEmptyPackages_DoesNotThrow()
    {
        // Arrange
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
 
        // Replace the NuGetPackageCache with our test implementation
        services.AddSingleton<INuGetPackageCache>(new FakeNuGetPackageCache());
        services.AddSingleton<ICliUpdateNotifier, CliUpdateNotifier>();
 
        using var provider = services.BuildServiceProvider();
        var service = provider.GetRequiredService<ICliUpdateNotifier>();
 
        // Act & Assert (should not throw)
        await service.CheckForCliUpdatesAsync(workspace.WorkspaceRoot, CancellationToken.None).DefaultTimeout();
        service.NotifyIfUpdateAvailable();
    }
 
    private static string CreateCustomToolPathInstall(string toolPath)
    {
        var processPath = Path.Combine(toolPath, GetAspireExecutableName());
        var storeExecutablePath = Path.Combine(
            toolPath,
            ".store",
            "aspire.cli",
            "9.4.0",
            "aspire.cli.linux-x64",
            "9.4.0",
            "tools",
            "net10.0",
            "linux-x64",
            GetAspireExecutableName());
 
        Directory.CreateDirectory(toolPath);
        Directory.CreateDirectory(Path.GetDirectoryName(storeExecutablePath)!);
        File.WriteAllText(processPath, string.Empty);
        File.WriteAllText(storeExecutablePath, string.Empty);
 
        return processPath;
    }
 
    private static string GetAspireExecutableName()
    {
        return OperatingSystem.IsWindows() ? "aspire.exe" : "aspire";
    }
 
    private static IReadOnlyDictionary<string, string?> CreateNpmInstallEnvironment()
    {
        return new Dictionary<string, string?>
        {
            [NpmInstallDetection.PackageEnvironmentVariableName] = NpmInstallDetection.ExpectedPackageName,
            [NpmInstallDetection.PackageVersionEnvironmentVariableName] = "9.4.0",
            [NpmInstallDetection.PackageRidEnvironmentVariableName] = "linux-x64"
        };
    }
 
    private static void UseProcessPath(CliServiceCollectionTestOptions options, string? processPath)
    {
        options.ProcessPathProviderFactory = _ => new TestProcessPathProvider(processPath);
    }
 
    private static CliUpdateNotifierWithPackageVersionOverride CreateCliUpdateNotifier(IServiceProvider serviceProvider, string currentVersion)
    {
        return new CliUpdateNotifierWithPackageVersionOverride(
            currentVersion,
            serviceProvider.GetRequiredService<ILogger<CliUpdateNotifier>>(),
            serviceProvider.GetRequiredService<INuGetPackageCache>(),
            serviceProvider.GetRequiredService<IInteractionService>(),
            serviceProvider.GetRequiredService<IProcessPathProvider>(),
            serviceProvider.GetRequiredService<CliExecutionContext>());
    }
}
 
internal sealed class CliUpdateNotifierWithPackageVersionOverride(
    string currentVersion,
    ILogger<CliUpdateNotifier> logger,
    INuGetPackageCache nuGetPackageCache,
    IInteractionService interactionService,
    IProcessPathProvider processPathProvider,
    CliExecutionContext executionContext) : CliUpdateNotifier(logger, nuGetPackageCache, interactionService, processPathProvider, executionContext)
{
    protected override SemVersion? GetCurrentVersion()
    {
        return SemVersion.Parse(currentVersion, SemVersionStyles.Strict);
    }
}