File: TestToolBuilder.cs
Web Access
Project: ..\..\..\test\Microsoft.DotNet.PackageInstall.Tests\Microsoft.DotNet.PackageInstall.Tests.csproj (Microsoft.DotNet.PackageInstall.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
namespace Microsoft.DotNet.PackageInstall.Tests
{
    [CollectionDefinition(nameof(TestToolBuilderCollection))]
    public class TestToolBuilderCollection : ICollectionFixture<TestToolBuilder>
    {
        // This class is intentionally left empty.
    }
 
 
    //  This class is responsible for creating test dotnet tool packages.  We don't want every test to have to create it's own tool package, as that could slow things down quite a bit.
    //  So this class handles creating each tool package once.  To use it, add it as a constructor parameter to your test class.  You will also need to add [Collection(nameof(TestToolBuilderCollection))]
    //  to your test class to ensure that only one test at a time uses this class.  xUnit will give you an error if you forget to add the collection attribute but do add the constructor parameter.
    //
    //  The TestToolBuilder class uses a common folder to store all the tool package projects and their built nupkgs.  When CreateTestTool is called, it will compare the contents of the project
    //  in the common folder to the contents of the project that is being requested.  If there are any differences, it will re-create the project and build it again.  It will also delete the package from the
    //  global packages folder to ensure that the newly built package is used the next time a test tries to install it.
    //
    //  The main thing this class can't handle is if the way the .NET SDK builds packages changes.  In CI runs, we should use a clean test execution folder each time (I think!), so this shouldn't be an issue.
    //  For local testing, you may need to delete the artifacts\tmp\Debug\testing\TestTools folder if the SDK changes in a way that affects the built package.
    public class TestToolBuilder
    {
        public class TestToolSettings
        {
            public string ToolPackageId { get; set; } = "TestTool";
            public string ToolPackageVersion { get; set; } = "1.0.0";
            public string ToolCommandName { get; set; } = "TestTool";
            public string[]? AdditionalPackageTypes { get; set; } = null;
            public bool NativeAOT { get; set { field = value; this.RidSpecific = value; } } = false;
            public bool SelfContained { get; set { field = value; this.RidSpecific = value; } } = false;
            public bool Trimmed { get; set { field = value; this.RidSpecific = value; } } = false;
 
            /// <summary>
            /// If set, the generated tool will include the <c>any</c> RID in the list of RIDs to target.
            /// This will cause a framework-dependent, platform-agnostic package to be created.
            /// </summary>
            public bool IncludeAnyRid { get; set { field = value; } } = false;
 
            /// <summary>
            /// If set, the generated tool will target all of the RIDs specified in <see cref="ToolsetInfo.LatestRuntimeIdentifiers"/>.
            /// Defaults to <see langword="false"/>.
            /// </summary>
            public bool RidSpecific { get; set; } = false;
 
            /// <summary>
            /// If set, the generated tool will include the current executing platform's RID in the list of RIDs to target
            /// (which is otherwise made of <see cref="ToolsetInfo.LatestRuntimeIdentifiers"/>.) If set to <see langword="false"/>,
            /// the current RID will be stripped from that set.
            /// Defaults to <see langword="true"/>.
            /// </summary>
            public bool IncludeCurrentRid { get; set; } = true;
 
            public string GetIdentifier() {
                var builder = new StringBuilder();
                builder.Append(ToolPackageId.ToLowerInvariant());
                builder.Append('-');
                builder.Append(ToolPackageVersion.ToLowerInvariant());
                builder.Append('-');
                builder.Append(ToolCommandName.ToLowerInvariant());
                if (NativeAOT)
                {
                    builder.Append("-nativeaot");
                }
                else if (SelfContained)
                {
                    builder.Append("-selfcontained");
                }
                else if (Trimmed)
                {
                    builder.Append("-trimmed");
                }
                else
                {
                    builder.Append("-managed");
                }
                if (RidSpecific)
                {
                    builder.Append("-specific");
                }
                if (IncludeAnyRid)
                {
                    builder.Append("-anyrid");
                }
                if (!IncludeCurrentRid)
                {
                    builder.Append("-no-current-rid");
                }
                if (AdditionalPackageTypes is not null && AdditionalPackageTypes.Length > 0)
                {
                    builder.Append('-');
                    builder.Append(string.Join("-", AdditionalPackageTypes.Select(p => p.ToLowerInvariant())));
                }
 
                return builder.ToString();
            }
        }
 
 
        public string CreateTestTool(ITestOutputHelper log, TestToolSettings toolSettings, bool collectBinlogs = true)
        {
            var targetDirectory = Path.Combine(TestContext.Current.TestExecutionDirectory, "TestTools", toolSettings.GetIdentifier());
 
            var testProject = new TestProject(toolSettings.ToolPackageId)
            {
                TargetFrameworks = ToolsetInfo.CurrentTargetFramework,
                IsExe = true,
            };
            testProject.AdditionalProperties["PackAsTool"] = "true";
            testProject.AdditionalProperties["ToolCommandName"] = toolSettings.ToolCommandName;
            testProject.AdditionalProperties["ImplicitUsings"] = "enable";
            testProject.AdditionalProperties["Version"] = toolSettings.ToolPackageVersion;
 
            var multiRid = toolSettings.IncludeCurrentRid ? ToolsetInfo.LatestRuntimeIdentifiers : ToolsetInfo.LatestRuntimeIdentifiers.Replace(RuntimeInformation.RuntimeIdentifier, string.Empty).Trim(';');
 
            if (toolSettings.RidSpecific)
            {
                testProject.AdditionalProperties["RuntimeIdentifiers"] = multiRid;
            }
            if (toolSettings.IncludeAnyRid)
            {
                testProject.AdditionalProperties["RuntimeIdentifiers"] = testProject.AdditionalProperties.TryGetValue("RuntimeIdentifiers", out var existingRids)
                    ? $"{existingRids};any"
                    : "any";
            }
 
            if (toolSettings.NativeAOT)
            {
                testProject.AdditionalProperties["PublishAot"] = "true";
            }
 
            if (toolSettings.SelfContained)
            {
                testProject.AdditionalProperties["SelfContained"] = "true";
            }
 
            if (toolSettings.Trimmed)
            {
                testProject.AdditionalProperties["PublishTrimmed"] = "true";
            }
 
            if (toolSettings.AdditionalPackageTypes is not null && toolSettings.AdditionalPackageTypes.Length > 0)
            {
                testProject.AdditionalProperties["PackageType"] = string.Join(";", toolSettings.AdditionalPackageTypes);
            }
 
            testProject.SourceFiles.Add("Program.cs", "Console.WriteLine(\"Hello Tool!\");");
            testProject.PackageReferences.Add(new("Microsoft.Data.Sqlite", version: "9.0.8"));
 
            var testAssetManager = new TestAssetsManager(log);
            var testAsset = testAssetManager.CreateTestProject(testProject, identifier: toolSettings.GetIdentifier());
 
            var testAssetProjectDirectory = Path.Combine(testAsset.Path, testProject.Name!);
 
            // Avoid rebuilding the package unless the project has changed.  If there is a difference in contents between the files from the TestProject and the target directory,
            // then we delete and recopy everything over.
            if (!AreDirectoriesEqual(testAssetProjectDirectory, targetDirectory))
            {
                if (Directory.Exists(targetDirectory))
                {
                    Directory.Delete(targetDirectory, true);
                }
 
                Directory.CreateDirectory(Path.GetDirectoryName(targetDirectory)!);
 
                Directory.Move(testAssetProjectDirectory, targetDirectory);
            }
 
            // If the .nupkg hasn't been created yet, then build it.  This may be because this is the first time we need the package, or because we've updated the tests and
            // the contents of the files have changed
            string packageOutputPath = Path.Combine(targetDirectory, "bin", "Release");
            if (!Directory.Exists(packageOutputPath) || Directory.GetFiles(packageOutputPath, "*.nupkg").Length == 0)
            {
                new DotnetPackCommand(log)
                    .WithWorkingDirectory(targetDirectory)
                    .Execute(collectBinlogs ? $"--bl:{toolSettings.GetIdentifier()}-{{}}" : "")
                    .Should().Pass();
 
                if (toolSettings.NativeAOT)
                {
                    //  For Native AOT tools, we need to repack the tool to include the runtime-specific files that were generated during publish
                    new DotnetPackCommand(log, "-r", RuntimeInformation.RuntimeIdentifier)
                        .WithWorkingDirectory(targetDirectory)
                        .Execute(collectBinlogs ? $"--bl:{toolSettings.GetIdentifier()}-{RuntimeInformation.RuntimeIdentifier}-{{}}" : "")
                        .Should().Pass();
                }
 
                //  If we have built a new package, delete any old versions of it from the global packages folder
                RemovePackageFromGlobalPackages(log, toolSettings.ToolPackageId, toolSettings.ToolPackageVersion);
            }
 
            return packageOutputPath;
        }
 
        public void RemovePackageFromGlobalPackages(ITestOutputHelper log, string packageId, string version)
        {
            var result = new DotnetCommand(log, "nuget", "locals", "global-packages", "--list")
                .Execute();
 
            result.Should().Pass();
 
            string? globalPackagesPath;
 
            var outputDict = result.StdOut!.Split(Environment.NewLine)
                .Select(l => l.Trim())
                .Where(l => !string.IsNullOrEmpty(l))
                .ToDictionary(l => l.Split(':')[0].Trim(), l => l.Split(':', count: 2)[1].Trim());
 
            if (!outputDict.TryGetValue("global-packages", out globalPackagesPath))
            {
                throw new InvalidOperationException("Could not determine global packages location.");
            }
 
            var packagePathInGlobalPackages = Path.Combine(globalPackagesPath, packageId.ToLowerInvariant(), version);
 
            if (Directory.Exists(packagePathInGlobalPackages))
            {
                Directory.Delete(packagePathInGlobalPackages, true);
            }
        }
 
        /// <summary>
        /// Compares the files in two directories (non-recursively) and returns true if they have the same files with identical text contents.
        /// </summary>
        /// <param name="dir1">First directory path</param>
        /// <param name="dir2">Second directory path</param>
        /// <returns>True if the directories have the same files with the same text contents, false otherwise.</returns>
        public static bool AreDirectoriesEqual(string dir1, string dir2)
        {
            if (!Directory.Exists(dir1) || !Directory.Exists(dir2))
                return false;
 
            var files1 = Directory.GetFiles(dir1);
            var files2 = Directory.GetFiles(dir2);
 
            if (files1.Length != files2.Length)
                return false;
 
            var fileNames1 = new HashSet<string>(Array.ConvertAll(files1, f => Path.GetFileName(f) ?? string.Empty), StringComparer.OrdinalIgnoreCase);
            var fileNames2 = new HashSet<string>(Array.ConvertAll(files2, f => Path.GetFileName(f) ?? string.Empty), StringComparer.OrdinalIgnoreCase);
 
            if (!fileNames1.SetEquals(fileNames2))
                return false;
 
            foreach (var fileName in fileNames1)
            {
                var filePath1 = Path.Combine(dir1, fileName);
                var filePath2 = Path.Combine(dir2, fileName);
                if (!File.Exists(filePath1) || !File.Exists(filePath2))
                    return false;
                var text1 = File.ReadAllText(filePath1);
                var text2 = File.ReadAllText(filePath2);
                if (!string.Equals(text1, text2, StringComparison.Ordinal))
                    return false;
            }
            return true;
        }
    }
}