File: Utils\DotnetSdkVersionProviderTests.cs
Web Access
Project: src\tests\Aspire.Hosting.Tests\Aspire.Hosting.Tests.csproj (Aspire.Hosting.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.Hosting.Dcp.Process;
using Aspire.Hosting.Tests.Utils;
using Aspire.Hosting.Utils;
using Microsoft.Extensions.Logging.Abstractions;
using Semver;
 
namespace Aspire.Hosting.Tests;
 
[Trait("Partition", "2")]
public class DotnetSdkVersionProviderTests(ITestOutputHelper outputHelper)
{
    [Theory]
    [InlineData(null, false)]
    [InlineData("10.0.999", false)]
    [InlineData("11.0.100-preview.7.25380.108", false)]
    [InlineData("11.0.100-beta.1", false)]
    [InlineData("11.0.100-rc.0.1", false)]
    [InlineData("11.0.100-rc.1", true)]
    [InlineData("11.0.100-rc.1.26425.128", true)]
    [InlineData("11.0.100-rc.2.1", true)]
    [InlineData("11.0.100", true)]
    [InlineData("11.0.101-servicing.1", true)]
    [InlineData("11.0.200-preview.1", true)]
    [InlineData("12.0.100-preview.1", true)]
    [InlineData("12.0.100-beta.1", true)]
    [InlineData("12.0.100-rc.1", true)]
    [InlineData("12.0.100", true)]
    [InlineData("13.0.100-alpha.1", true)]
    [InlineData("11.0.100-rc.1+build.42", true)]
    public void SupportsMultiThreadedBuildUsesSemVersionPrecedence(string? version, bool expected)
    {
        var parsedVersion = version is null
            ? null
            : SemVersion.Parse(version, SemVersionStyles.Strict);
 
        Assert.Equal(expected, DotnetSdkUtils.SupportsMultiThreadedBuild(parsedVersion));
    }
 
    [Theory]
    [InlineData(null, false)]
    [InlineData("10.0.999", false)]
    [InlineData("11.0.100-preview.7.25380.108", false)]
    [InlineData("11.0.100-rc.1.26425.128", false)]
    [InlineData("11.0.100-rc.2.1", false)]
    [InlineData("11.0.100-rc.3.99999.1", false)]
    [InlineData("11.0.100-rtm.26473.103", false)]
    [InlineData("11.0.100-rtm.26473.104", true)]
    [InlineData("11.0.100-rtm.26473.105", true)]
    [InlineData("11.0.100-rtm.26473.104+build.42", true)]
    [InlineData("11.0.100", true)]
    [InlineData("11.0.101-servicing.1", true)]
    [InlineData("11.0.200-preview.1", true)]
    [InlineData("12.0.100-preview.1", true)]
    public void SupportsFileBasedMultiThreadedBuildUsesVerifiedSdkFloor(string? version, bool expected)
    {
        var parsedVersion = version is null
            ? null
            : SemVersion.Parse(version, SemVersionStyles.Strict);
 
        Assert.Equal(expected, DotnetSdkUtils.SupportsFileBasedMultiThreadedBuild(parsedVersion));
    }
 
    [Fact]
    public async Task TryGetVersionAsyncRunsQuietVersionProbe()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var processRunner = new TestProcessRunner();
        processRunner.EnqueueResult(output: ["11.0.100-rc.1.26425.128"]);
        var provider = CreateProvider(processRunner);
 
        var version = await provider.TryGetVersionAsync(
            workspace.Path,
            TestContext.Current.CancellationToken);
 
        Assert.Equal("11.0.100-rc.1.26425.128", version?.ToString());
        var processSpec = Assert.Single(processRunner.ProcessSpecs);
        Assert.Equal("dotnet", processSpec.ExecutablePath);
        Assert.Equal(workspace.Path, processSpec.WorkingDirectory);
        Assert.Equal(["--version"], processSpec.ArgumentList);
        Assert.True(processSpec.ResolveExecutablePath);
        Assert.False(processSpec.ThrowOnNonZeroReturnCode);
        Assert.Equal("true", processSpec.EnvironmentVariables["DOTNET_NOLOGO"]);
        Assert.Equal("true", processSpec.EnvironmentVariables["DOTNET_CLI_TELEMETRY_OPTOUT"]);
    }
 
    [Fact]
    public async Task SupportsMultiThreadedBuildAsyncUsesBuildEnvironment()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var processRunner = new TestProcessRunner();
        processRunner.EnqueueResult(output: ["11.0.100-rc.1"]);
        var provider = CreateProvider(processRunner);
        var buildEnvironment = new Dictionary<string, string>
        {
            ["PATH"] = "custom-dotnet-path",
            ["DOTNET_NOLOGO"] = "false",
        };
 
        var supported = await provider.SupportsMultiThreadedBuildAsync(
            workspace.Path,
            buildEnvironment,
            TestContext.Current.CancellationToken);
 
        Assert.True(supported);
        var processSpec = Assert.Single(processRunner.ProcessSpecs);
        Assert.Equal("custom-dotnet-path", processSpec.EnvironmentVariables["PATH"]);
        Assert.Equal("false", processSpec.EnvironmentVariables["DOTNET_NOLOGO"]);
        Assert.Equal("true", processSpec.EnvironmentVariables["DOTNET_CLI_TELEMETRY_OPTOUT"]);
    }
 
    [Fact]
    public async Task SupportsFileBasedMultiThreadedBuildAsyncUsesBuildEnvironment()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var processRunner = new TestProcessRunner();
        processRunner.EnqueueResult(output: ["11.0.100-rtm.26473.104"]);
        var provider = CreateProvider(processRunner);
        var buildEnvironment = new Dictionary<string, string>
        {
            ["PATH"] = "custom-dotnet-path",
            ["DOTNET_NOLOGO"] = "false",
        };
 
        var supported = await provider.SupportsFileBasedMultiThreadedBuildAsync(
            workspace.Path,
            buildEnvironment,
            TestContext.Current.CancellationToken);
 
        Assert.True(supported);
        var processSpec = Assert.Single(processRunner.ProcessSpecs);
        Assert.Equal("custom-dotnet-path", processSpec.EnvironmentVariables["PATH"]);
        Assert.Equal("false", processSpec.EnvironmentVariables["DOTNET_NOLOGO"]);
        Assert.Equal("true", processSpec.EnvironmentVariables["DOTNET_CLI_TELEMETRY_OPTOUT"]);
    }
 
    [Fact]
    public async Task SupportsMultiThreadedBuildAsyncCachesByEnvironmentContents()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var processRunner = new TestProcessRunner();
        processRunner.EnqueueResult(output: ["11.0.100-rc.1"]);
        processRunner.EnqueueResult(output: ["10.0.999"]);
        var provider = CreateProvider(processRunner);
        var firstEnvironment = new Dictionary<string, string>
        {
            ["PATH"] = "first-dotnet-path",
            ["BUILD_FLAVOR"] = "custom",
        };
        var equivalentEnvironment = new Dictionary<string, string>
        {
            ["BUILD_FLAVOR"] = "custom",
            ["PATH"] = "first-dotnet-path",
        };
        var changedEnvironment = new Dictionary<string, string>
        {
            ["PATH"] = "second-dotnet-path",
            ["BUILD_FLAVOR"] = "custom",
        };
 
        var first = await provider.SupportsMultiThreadedBuildAsync(
            workspace.Path,
            firstEnvironment,
            TestContext.Current.CancellationToken);
        var equivalent = await provider.SupportsMultiThreadedBuildAsync(
            workspace.Path,
            equivalentEnvironment,
            TestContext.Current.CancellationToken);
        var changed = await provider.SupportsMultiThreadedBuildAsync(
            workspace.Path,
            changedEnvironment,
            TestContext.Current.CancellationToken);
 
        Assert.True(first);
        Assert.True(equivalent);
        Assert.False(changed);
        Assert.Collection(
            processRunner.ProcessSpecs,
            processSpec => Assert.Equal(
                "first-dotnet-path",
                processSpec.EnvironmentVariables["PATH"]),
            processSpec => Assert.Equal(
                "second-dotnet-path",
                processSpec.EnvironmentVariables["PATH"]));
    }
 
    [Fact]
    public async Task TryGetVersionAsyncCoalescesConcurrentRequests()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var processRunner = new TestProcessRunner();
        var processCompletion = new TaskCompletionSource<ProcessResult>(
            TaskCreationOptions.RunContinuationsAsynchronously);
        processRunner.EnqueuePending(processCompletion.Task);
        var provider = CreateProvider(processRunner);
 
        var first = provider.TryGetVersionAsync(
            workspace.Path,
            TestContext.Current.CancellationToken);
        await processRunner.RunStarted.Task.WaitAsync(TestContext.Current.CancellationToken);
        var second = provider.TryGetVersionAsync(
            workspace.Path,
            TestContext.Current.CancellationToken);
 
        Assert.Single(processRunner.ProcessSpecs);
 
        processCompletion.SetResult(new ProcessResult(0, ["11.0.100-rc.1"]));
        var versions = await Task.WhenAll(first, second);
 
        Assert.All(versions, version => Assert.Equal("11.0.100-rc.1", version?.ToString()));
        Assert.Single(processRunner.ProcessSpecs);
    }
 
    [Fact]
    public async Task TryGetVersionAsyncSharesNearestGlobalJsonContext()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        File.WriteAllText(Path.Combine(workspace.Path, "global.json"), "{}");
        var firstDirectory = Directory.CreateDirectory(Path.Combine(workspace.Path, "src", "First")).FullName;
        var secondDirectory = Directory.CreateDirectory(Path.Combine(workspace.Path, "src", "Second")).FullName;
        var processRunner = new TestProcessRunner();
        processRunner.EnqueueResult(output: ["11.0.100"]);
        var provider = CreateProvider(processRunner);
 
        var first = await provider.TryGetVersionAsync(
            firstDirectory,
            TestContext.Current.CancellationToken);
        var second = await provider.TryGetVersionAsync(
            secondDirectory,
            TestContext.Current.CancellationToken);
 
        Assert.Equal(first, second);
        Assert.Single(processRunner.ProcessSpecs);
    }
 
    [Fact]
    public async Task TryGetVersionAsyncSeparatesDifferentGlobalJsonContexts()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var firstDirectory = Directory.CreateDirectory(Path.Combine(workspace.Path, "First")).FullName;
        var secondDirectory = Directory.CreateDirectory(Path.Combine(workspace.Path, "Second")).FullName;
        File.WriteAllText(Path.Combine(firstDirectory, "global.json"), "{}");
        File.WriteAllText(Path.Combine(secondDirectory, "global.json"), "{}");
        var processRunner = new TestProcessRunner();
        processRunner.EnqueueResult(output: ["11.0.100"]);
        processRunner.EnqueueResult(output: ["12.0.100-preview.1"]);
        var provider = CreateProvider(processRunner);
 
        var first = await provider.TryGetVersionAsync(
            firstDirectory,
            TestContext.Current.CancellationToken);
        var second = await provider.TryGetVersionAsync(
            secondDirectory,
            TestContext.Current.CancellationToken);
 
        Assert.Equal("11.0.100", first?.ToString());
        Assert.Equal("12.0.100-preview.1", second?.ToString());
        Assert.Equal(2, processRunner.ProcessSpecs.Count);
    }
 
    [Fact]
    public async Task TryGetVersionAsyncRefreshesWhenGlobalJsonChanges()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var globalJsonPath = Path.Combine(workspace.Path, "global.json");
        File.WriteAllText(globalJsonPath, "{}");
        var processRunner = new TestProcessRunner();
        processRunner.EnqueueResult(output: ["11.0.100"]);
        processRunner.EnqueueResult(output: ["10.0.100"]);
        var provider = CreateProvider(processRunner);
 
        var first = await provider.TryGetVersionAsync(
            workspace.Path,
            TestContext.Current.CancellationToken);
        File.WriteAllText(globalJsonPath, """{"sdk":{"version":"10.0.100"}}""");
        var second = await provider.TryGetVersionAsync(
            workspace.Path,
            TestContext.Current.CancellationToken);
 
        Assert.Equal("11.0.100", first?.ToString());
        Assert.Equal("10.0.100", second?.ToString());
        Assert.Equal(2, processRunner.ProcessSpecs.Count);
    }
 
    [Fact]
    public async Task TryGetVersionAsyncRetriesAfterFailedProbe()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var processRunner = new TestProcessRunner();
        processRunner.EnqueueResult(exitCode: 1);
        processRunner.EnqueueResult(output: ["11.0.100-rc.1"]);
        var provider = CreateProvider(processRunner);
 
        var failed = await provider.TryGetVersionAsync(
            workspace.Path,
            TestContext.Current.CancellationToken);
        var retried = await provider.TryGetVersionAsync(
            workspace.Path,
            TestContext.Current.CancellationToken);
 
        Assert.Null(failed);
        Assert.Equal("11.0.100-rc.1", retried?.ToString());
        Assert.Equal(2, processRunner.ProcessSpecs.Count);
    }
 
    [Fact]
    public async Task TryGetVersionAsyncReturnsNullForMalformedOutput()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var processRunner = new TestProcessRunner();
        processRunner.EnqueueResult(output: ["not-a-version"]);
        var provider = CreateProvider(processRunner);
 
        var version = await provider.TryGetVersionAsync(
            workspace.Path,
            TestContext.Current.CancellationToken);
 
        Assert.Null(version);
    }
 
    [Fact]
    public async Task CanceledConsumerDoesNotRetainFailedProbe()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var processRunner = new TestProcessRunner();
        var processCompletion = new TaskCompletionSource<ProcessResult>(
            TaskCreationOptions.RunContinuationsAsynchronously);
        processRunner.EnqueuePending(processCompletion.Task);
        var provider = CreateProvider(processRunner);
        using var callerCancellationSource = new CancellationTokenSource();
 
        var canceledCall = provider.TryGetVersionAsync(
            workspace.Path,
            callerCancellationSource.Token);
        await processRunner.RunStarted.Task.WaitAsync(TestContext.Current.CancellationToken);
        var sharedCall = provider.TryGetVersionAsync(
            workspace.Path,
            TestContext.Current.CancellationToken);
        callerCancellationSource.Cancel();
 
        await Assert.ThrowsAnyAsync<OperationCanceledException>(() => canceledCall);
        processCompletion.SetResult(new ProcessResult(1));
        Assert.Null(await sharedCall);
        Assert.Equal(1, Assert.Single(processRunner.Disposables).DisposeCallCount);
 
        processRunner.EnqueueResult(output: ["11.0.100-rc.1"]);
        var retried = await provider.TryGetVersionAsync(
            workspace.Path,
            TestContext.Current.CancellationToken);
 
        Assert.Equal("11.0.100-rc.1", retried?.ToString());
        Assert.Equal(2, processRunner.ProcessSpecs.Count);
    }
 
    [Fact]
    public async Task ApplicationStoppingCancelsProbeAndDisposesProcess()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var processRunner = new TestProcessRunner();
        var processCompletion = new TaskCompletionSource<ProcessResult>(
            TaskCreationOptions.RunContinuationsAsynchronously);
        processRunner.EnqueuePending(processCompletion.Task);
        using var applicationStoppingSource = new CancellationTokenSource();
        var provider = CreateProvider(processRunner, applicationStoppingSource.Token);
 
        var versionTask = provider.TryGetVersionAsync(
            workspace.Path,
            TestContext.Current.CancellationToken);
        await processRunner.RunStarted.Task.WaitAsync(TestContext.Current.CancellationToken);
        applicationStoppingSource.Cancel();
 
        Assert.Null(await versionTask);
        Assert.Equal(1, Assert.Single(processRunner.Disposables).DisposeCallCount);
    }
 
    private static DotnetSdkVersionProvider CreateProvider(
        IProcessRunner processRunner,
        CancellationToken applicationStopping = default) =>
        new(
            processRunner,
            NullLogger<DotnetSdkVersionProvider>.Instance,
            applicationStopping);
}