File: BuildTransitive\GrpcNetClientFactoryVersionTargetTests.cs
Project: ..\..\..\test\Libraries\Microsoft.Extensions.Http.Resilience.Tests\Microsoft.Extensions.Http.Resilience.Tests.csproj (Microsoft.Extensions.Http.Resilience.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;
using System.Diagnostics;
using System.IO;
using System.Runtime.InteropServices;
using System.Security;
#if NETFRAMEWORK
using System.Text;
#endif
using System.Threading.Tasks;
using FluentAssertions;
using Xunit;
 
namespace Microsoft.Extensions.Http.Resilience.Test.BuildTransitive;
 
public class GrpcNetClientFactoryVersionTargetTests
{
    private const string WarningMessage = "Grpc.Net.ClientFactory 2.63.0 or earlier could cause issues";
 
    public static TheoryData<string, string> CompatibleVersionSources => new()
    {
        {
            "PackageReference.Version",
            """
            <ItemGroup>
              <PackageReference Include="Grpc.Net.ClientFactory" Version="[2.80.0]" />
            </ItemGroup>
            """
        },
        {
            "PackageReference.VersionOverride",
            """
            <PropertyGroup>
              <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
            </PropertyGroup>
            <ItemGroup>
              <PackageReference Include="Grpc.Net.ClientFactory" VersionOverride="[2.80.0]" />
            </ItemGroup>
            """
        },
        {
            "PackageVersion.Version",
            """
            <PropertyGroup>
              <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
            </PropertyGroup>
            <ItemGroup>
              <PackageReference Include="Grpc.Net.ClientFactory" />
              <PackageVersion Include="Grpc.Net.ClientFactory" Version="[2.80.0]" />
            </ItemGroup>
            """
        },
        {
            "ReferencePath.NuGetPackageVersion",
            """
            <ItemGroup>
              <ReferencePath Include="Grpc.Net.ClientFactory.dll" NuGetPackageId="Grpc.Net.ClientFactory" NuGetPackageVersion="[2.80.0]" />
            </ItemGroup>
            """
        },
        {
            "PackageReference.Version.Unpinned",
            """
            <ItemGroup>
              <PackageReference Include="Grpc.Net.ClientFactory" Version="2.80.0" />
            </ItemGroup>
            """
        },
        {
            "PackageReference.VersionOverride.Unpinned",
            """
            <PropertyGroup>
              <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
            </PropertyGroup>
            <ItemGroup>
              <PackageReference Include="Grpc.Net.ClientFactory" VersionOverride="2.80.0" />
            </ItemGroup>
            """
        },
        {
            "PackageVersion.Version.Unpinned",
            """
            <PropertyGroup>
              <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
            </PropertyGroup>
            <ItemGroup>
              <PackageReference Include="Grpc.Net.ClientFactory" />
              <PackageVersion Include="Grpc.Net.ClientFactory" Version="2.80.0" />
            </ItemGroup>
            """
        },
        {
            "ReferencePath.NuGetPackageVersion.Unpinned",
            """
            <ItemGroup>
              <ReferencePath Include="Grpc.Net.ClientFactory.dll" NuGetPackageId="Grpc.Net.ClientFactory" NuGetPackageVersion="2.80.0" />
            </ItemGroup>
            """
        },
    };
 
    public static TheoryData<string, string> IncompatibleVersionSources => new()
    {
        {
            "PackageReference.Version",
            """
            <ItemGroup>
              <PackageReference Include="Grpc.Net.ClientFactory" Version="[2.63.0]" />
            </ItemGroup>
            """
        },
        {
            "PackageReference.VersionOverride",
            """
            <PropertyGroup>
              <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
            </PropertyGroup>
            <ItemGroup>
              <PackageReference Include="Grpc.Net.ClientFactory" VersionOverride="[2.63.0]" />
            </ItemGroup>
            """
        },
        {
            "PackageVersion.Version",
            """
            <PropertyGroup>
              <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
            </PropertyGroup>
            <ItemGroup>
              <PackageReference Include="Grpc.Net.ClientFactory" />
              <PackageVersion Include="Grpc.Net.ClientFactory" Version="[2.63.0]" />
            </ItemGroup>
            """
        },
        {
            "ReferencePath.NuGetPackageVersion",
            """
            <ItemGroup>
              <ReferencePath Include="Grpc.Net.ClientFactory.dll" NuGetPackageId="Grpc.Net.ClientFactory" NuGetPackageVersion="[2.63.0]" />
            </ItemGroup>
            """
        },
        {
            "PackageReference.Version.Unpinned",
            """
            <ItemGroup>
              <PackageReference Include="Grpc.Net.ClientFactory" Version="2.63.0" />
            </ItemGroup>
            """
        },
        {
            "PackageReference.VersionOverride.Unpinned",
            """
            <PropertyGroup>
              <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
            </PropertyGroup>
            <ItemGroup>
              <PackageReference Include="Grpc.Net.ClientFactory" VersionOverride="2.63.0" />
            </ItemGroup>
            """
        },
        {
            "PackageVersion.Version.Unpinned",
            """
            <PropertyGroup>
              <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
            </PropertyGroup>
            <ItemGroup>
              <PackageReference Include="Grpc.Net.ClientFactory" />
              <PackageVersion Include="Grpc.Net.ClientFactory" Version="2.63.0" />
            </ItemGroup>
            """
        },
        {
            "ReferencePath.NuGetPackageVersion.Unpinned",
            """
            <ItemGroup>
              <ReferencePath Include="Grpc.Net.ClientFactory.dll" NuGetPackageId="Grpc.Net.ClientFactory" NuGetPackageVersion="2.63.0" />
            </ItemGroup>
            """
        },
    };
 
    [Theory]
    [MemberData(nameof(CompatibleVersionSources))]
    public async Task CheckGrpcNetClientFactoryVersion_CompatibleVersion_DoesNotWarnOrFail(
        string scenario,
        string projectItems)
    {
        var result = await RunTargetAsync(projectItems);
 
        result.ExitCode.Should().Be(0, result.ToString());
        result.Output.Should().NotContain("MSB4184", scenario);
        result.Output.Should().NotContain(WarningMessage, scenario);
    }
 
    [Theory]
    [MemberData(nameof(IncompatibleVersionSources))]
    public async Task CheckGrpcNetClientFactoryVersion_IncompatibleVersion_Warns(
        string scenario,
        string projectItems)
    {
        var result = await RunTargetAsync(projectItems);
 
        result.ExitCode.Should().Be(0, result.ToString());
        result.Output.Should().NotContain("MSB4184", scenario);
        result.Output.Should().Contain(WarningMessage, scenario);
    }
 
    [Theory]
    [InlineData("(,2.80.0]")]
    [InlineData("[2.64.0-preview.1]")]
    public async Task CheckGrpcNetClientFactoryVersion_VersionWithoutComparableSystemVersion_DoesNotFail(string version)
    {
        var result = await RunTargetAsync($"""
            <ItemGroup>
              <PackageReference Include="Grpc.Net.ClientFactory" Version="{version}" />
            </ItemGroup>
            """);
 
        result.ExitCode.Should().Be(0, result.ToString());
        result.Output.Should().NotContain("MSB4184");
        result.Output.Should().NotContain(WarningMessage);
    }
 
    private static async Task<CommandResult> RunTargetAsync(string projectItems)
    {
        var tempDirectory = Path.Combine(Path.GetTempPath(), $"GrpcNetClientFactoryTargetTests_{Guid.NewGuid():N}");
        Directory.CreateDirectory(tempDirectory);
 
        try
        {
            var projectPath = Path.Combine(tempDirectory, "test.proj");
            var project = $"""
                <Project>
                  <Import Project="{EscapeXml(GetTargetPath())}" />
                {projectItems}
                </Project>
                """;
 
            File.WriteAllText(projectPath, project);
 
            return await RunDotNetAsync(tempDirectory, "msbuild", projectPath, "-nologo", "-v:minimal", "-t:_CheckGrpcNetClientFactoryVersion").ConfigureAwait(false);
        }
        finally
        {
            DeleteDirectoryBestEffort(tempDirectory);
        }
    }
 
    private static void DeleteDirectoryBestEffort(string directory)
    {
        for (var retry = 0; retry < 3; retry++)
        {
            try
            {
                Directory.Delete(directory, recursive: true);
                return;
            }
            catch (IOException)
            {
                // Best-effort cleanup should not mask the test failure.
                DelayBeforeCleanupRetry(retry);
            }
            catch (UnauthorizedAccessException)
            {
                // Best-effort cleanup should not mask the test failure.
                DelayBeforeCleanupRetry(retry);
            }
        }
    }
 
    private static void DelayBeforeCleanupRetry(int retry)
    {
        System.Threading.Thread.Sleep(TimeSpan.FromMilliseconds(50 * (retry + 1)));
    }
 
    private static async Task<CommandResult> RunDotNetAsync(string workingDirectory, params string[] arguments)
    {
        var processStartInfo = new ProcessStartInfo(GetDotNetPath())
        {
            WorkingDirectory = workingDirectory,
            RedirectStandardOutput = true,
            RedirectStandardError = true,
            UseShellExecute = false,
            CreateNoWindow = true,
#if NETFRAMEWORK
            Arguments = CreateArguments(arguments),
#endif
        };
 
#if !NETFRAMEWORK
        foreach (var argument in arguments)
        {
            processStartInfo.ArgumentList.Add(argument);
        }
#endif
 
        using var process = Process.Start(processStartInfo) ?? throw new InvalidOperationException("Failed to start dotnet.");
 
        var standardOutputTask = process.StandardOutput.ReadToEndAsync();
        var standardErrorTask = process.StandardError.ReadToEndAsync();
 
        if (!process.WaitForExit((int)TimeSpan.FromSeconds(30).TotalMilliseconds))
        {
            KillProcess(process);
            process.WaitForExit();
            await ObserveOutputTasksAsync(standardOutputTask, standardErrorTask).ConfigureAwait(false);
            throw new TimeoutException("Timed out while running dotnet msbuild.");
        }
 
        var standardOutput = await standardOutputTask.ConfigureAwait(false);
        var standardError = await standardErrorTask.ConfigureAwait(false);
 
        return new CommandResult(process.ExitCode, standardOutput, standardError);
    }
 
    private static void KillProcess(Process process)
    {
        try
        {
#if NETFRAMEWORK
            process.Kill();
#else
            process.Kill(entireProcessTree: true);
#endif
        }
        catch (InvalidOperationException exception)
        {
            _ = exception;
        }
    }
 
    private static async Task ObserveOutputTasksAsync(Task<string> standardOutputTask, Task<string> standardErrorTask)
    {
        try
        {
#pragma warning disable VSTHRD003 // Output reader tasks are intentionally observed after the process exits.
            await Task.WhenAll(standardOutputTask, standardErrorTask).ConfigureAwait(false);
#pragma warning restore VSTHRD003
        }
        catch (IOException exception)
        {
            _ = exception;
        }
        catch (ObjectDisposedException exception)
        {
            _ = exception;
        }
    }
 
#if NETFRAMEWORK
    private static string CreateArguments(params string[] arguments)
    {
        return string.Join(" ", Array.ConvertAll(arguments, QuoteArgument));
    }
 
    private static string QuoteArgument(string argument)
    {
        var quoted = new StringBuilder();
        quoted.Append('"');
 
        var backslashCount = 0;
        foreach (var character in argument)
        {
            if (character == '\\')
            {
                backslashCount++;
            }
            else if (character == '"')
            {
                quoted.Append('\\', (backslashCount * 2) + 1);
                quoted.Append('"');
                backslashCount = 0;
            }
            else
            {
                quoted.Append('\\', backslashCount);
                quoted.Append(character);
                backslashCount = 0;
            }
        }
 
        quoted.Append('\\', backslashCount * 2);
        quoted.Append('"');
 
        return quoted.ToString();
    }
#endif
 
    private static string GetTargetPath()
    {
        var repoRoot = GetRepoRoot();
        return Path.Combine(
            repoRoot,
            "src",
            "Libraries",
            "Microsoft.Extensions.Http.Resilience",
            "buildTransitive",
            "Microsoft.Extensions.Http.Resilience.targets");
    }
 
    private static string GetDotNetPath()
    {
        var dotnetFileName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "dotnet.exe" : "dotnet";
        var repoDotnetPath = Path.Combine(GetRepoRoot(), ".dotnet", dotnetFileName);
 
        return File.Exists(repoDotnetPath) ? repoDotnetPath : dotnetFileName;
    }
 
    private static string GetRepoRoot()
    {
        var directory = new DirectoryInfo(AppContext.BaseDirectory);
 
        while (directory is not null)
        {
            var targetPath = Path.Combine(
                directory.FullName,
                "src",
                "Libraries",
                "Microsoft.Extensions.Http.Resilience",
                "buildTransitive",
                "Microsoft.Extensions.Http.Resilience.targets");
 
            if (File.Exists(targetPath))
            {
                return directory.FullName;
            }
 
            directory = directory.Parent;
        }
 
        throw new InvalidOperationException("Failed to locate the repository root.");
    }
 
    private static string EscapeXml(string value)
    {
        return SecurityElement.Escape(value) ?? string.Empty;
    }
 
    private sealed record CommandResult(int ExitCode, string StandardOutput, string StandardError)
    {
        public string Output => StandardOutput + StandardError;
 
        public override string ToString()
        {
            return $"""
                Exit code: {ExitCode}
                Standard output:
                {StandardOutput}
                Standard error:
                {StandardError}
                """;
        }
    }
}