diff --git a/src/Resolvers/Microsoft.DotNet.MSBuildSdkResolver/MSBuildSdkResolver.cs b/src/Resolvers/Microsoft.DotNet.MSBuildSdkResolver/MSBuildSdkResolver.cs index 2843c1de5828..025f85bb27b1 100644 --- a/src/Resolvers/Microsoft.DotNet.MSBuildSdkResolver/MSBuildSdkResolver.cs +++ b/src/Resolvers/Microsoft.DotNet.MSBuildSdkResolver/MSBuildSdkResolver.cs @@ -196,9 +196,9 @@ private sealed class CachedState minimumVSDefinedSDKVersion); } - string? dotnetExe = dotnetRoot != null ? - Path.Combine(dotnetRoot, Constants.DotNetExe) : - null; + string? dotnetExe = + TryResolveDotnetExeFromSdkResolution(resolverResult) + ?? Path.Combine(dotnetRoot, RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? Constants.DotNetExe : Constants.DotNet); if (File.Exists(dotnetExe)) { propertiesToAdd ??= new Dictionary(); @@ -288,6 +288,25 @@ private sealed class CachedState return factory.IndicateSuccess(msbuildSdkDir, netcoreSdkVersion, propertiesToAdd, itemsToAdd, warnings); } + /// Try to find the dotnet binary from the SDK resolution result upwards + private static string? TryResolveDotnetExeFromSdkResolution(SdkResolutionResult resolverResult) + { + var expectedFileName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? Constants.DotNetExe : Constants.DotNet; + var currentDir = resolverResult.ResolvedSdkDirectory; + while (currentDir != null) + { + var dotnetExe = Path.Combine(currentDir, expectedFileName); + if (File.Exists(dotnetExe)) + { + return dotnetExe; + } + + currentDir = Path.GetDirectoryName(currentDir); + } + + return null; + } + private static string? GetMSbuildRuntimeVersion(string sdkDirectory, string dotnetRoot) { // 1. Get the runtime version from the MSBuild.runtimeconfig.json file diff --git a/src/Resolvers/Microsoft.DotNet.SdkResolver/VSSettings.cs b/src/Resolvers/Microsoft.DotNet.SdkResolver/VSSettings.cs index e72403408031..d2d22b1b90f5 100644 --- a/src/Resolvers/Microsoft.DotNet.SdkResolver/VSSettings.cs +++ b/src/Resolvers/Microsoft.DotNet.SdkResolver/VSSettings.cs @@ -63,7 +63,7 @@ private VSSettings() } // Test constructor - public VSSettings(string settingsFilePath, bool disallowPrereleaseByDefault) + public VSSettings(string? settingsFilePath, bool disallowPrereleaseByDefault) { _settingsFilePath = settingsFilePath; _disallowPrereleaseByDefault = disallowPrereleaseByDefault; diff --git a/test/Microsoft.DotNet.MSBuildSdkResolver.Tests/GivenAnMSBuildSdkResolver.cs b/test/Microsoft.DotNet.MSBuildSdkResolver.Tests/GivenAnMSBuildSdkResolver.cs index 7ad39cdb9c29..cebe1a8152b8 100644 --- a/test/Microsoft.DotNet.MSBuildSdkResolver.Tests/GivenAnMSBuildSdkResolver.cs +++ b/test/Microsoft.DotNet.MSBuildSdkResolver.Tests/GivenAnMSBuildSdkResolver.cs @@ -1,8 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -#nullable disable - extern alias sdkResolver; using System.Runtime.CompilerServices; using Microsoft.Build.Framework; @@ -79,7 +77,7 @@ public void ItFindsTheVersionSpecifiedInGlobalJson() [Theory] [InlineData(null)] [InlineData("")] - public void ItUsesProjectDirectoryIfSolutionFilePathIsNullOrWhitespace(string solutionFilePath) + public void ItUsesProjectDirectoryIfSolutionFilePathIsNullOrWhitespace(string? solutionFilePath) { const string version = "99.0.0"; @@ -106,7 +104,7 @@ public void ItUsesProjectDirectoryIfSolutionFilePathIsNullOrWhitespace(string so [InlineData("", null)] [InlineData("", "")] [InlineData(null, "")] - public void ItUsesCurrentDirectoryIfSolutionFilePathAndProjectFilePathIsNullOrWhitespace(string solutionFilePath, string projectFilePath) + public void ItUsesCurrentDirectoryIfSolutionFilePathAndProjectFilePathIsNullOrWhitespace(string? solutionFilePath, string? projectFilePath) { const string version = "99.0.0"; @@ -207,12 +205,12 @@ public void ItReturnsHighestSdkAvailableThatIsCompatibleWithMSBuild(bool disallo if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { // DotnetHost is the path to dotnet.exe. Can be only on Windows. - result.PropertiesToAdd.Count.Should().Be(2); + result.PropertiesToAdd.Should().NotBeNull().And.HaveCount(2); result.PropertiesToAdd.Should().ContainKey(DotnetHostExperimentalKey); } else { - result.PropertiesToAdd.Count.Should().Be(1); + result.PropertiesToAdd.Should().NotBeNull().And.HaveCount(1); } result.PropertiesToAdd.Should().ContainKey(MSBuildTaskHostRuntimeVersion); result.PropertiesToAdd[MSBuildTaskHostRuntimeVersion].Should().Be("mockRuntimeVersion"); @@ -221,6 +219,41 @@ public void ItReturnsHighestSdkAvailableThatIsCompatibleWithMSBuild(bool disallo result.Errors.Should().BeNullOrEmpty(); } + [Fact] + public void WhenALocalSdkIsResolvedItReturnsHostFromThatSDKInsteadOfAmbientGlobalSdk() + { + // create a test that sets up a TestEnvironment with + // * an ambient global SDK + // * a different-versioned SDK that's in a different location + // * a global.json with sdk.paths that prefers the different-versioned SDK + // assert that when we resolve, we return the path to the different-versioned SDK's dotnet.exe + var environment = new TestEnvironment(_testAssetsManager); + var localSdkRoot = Path.Combine("some", "local", "dir"); + var localSdkDotnetRoot = Path.Combine(environment.TestDirectory.FullName, localSdkRoot, "dotnet"); + var ambientSdkDotnetRoot = Path.Combine(environment.GetProgramFilesDirectory(ProgramFiles.X64).FullName, "dotnet"); + var ambientMSBuildSkRoot = environment.CreateSdkDirectory(ProgramFiles.X64, "Some.Test.Sdk", "1.2.3"); + var localPathMSBuildSdkRoot = environment.CreateSdkDirectory(localSdkRoot, "Some.Test.Sdk", "1.2.4"); + var ambientDotnetBinary = environment.CreateMuxerAndAddToPath(ProgramFiles.X64); + var localDotnetBinary = environment.CreateMuxer(localSdkRoot); + environment.CreateGlobalJson(environment.TestDirectory, "1.2.3", [localSdkDotnetRoot, ambientSdkDotnetRoot]); + + var resolver = environment.CreateResolver(); + var context = new MockContext(Log) + { + MSBuildVersion = new Version(20, 0, 0, 0), + ProjectFileDirectory = environment.TestDirectory, + IsRunningInVisualStudio = false + }; + var result = (MockResult)resolver.Resolve( + new SdkReference("Some.Test.Sdk", null, null), + context, + new MockFactory()); + result.Success.Should().BeTrue(); + result.PropertiesToAdd.Should().NotBeNull().And.HaveCount(2); + result.PropertiesToAdd.Should().ContainKey(DotnetHostExperimentalKey); + result.PropertiesToAdd[DotnetHostExperimentalKey].Should().Be(localDotnetBinary); + } + [Theory] [InlineData(true)] [InlineData(false)] @@ -292,12 +325,12 @@ public void ItReturnsHighestSdkAvailableThatIsCompatibleWithMSBuildWhenVersionIn if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { // DotnetHost is the path to dotnet.exe. Can be only on Windows. - result.PropertiesToAdd.Count.Should().Be(4); + result.PropertiesToAdd.Should().NotBeNull().And.HaveCount(4); result.PropertiesToAdd.Should().ContainKey(DotnetHostExperimentalKey); } else { - result.PropertiesToAdd.Count.Should().Be(3); + result.PropertiesToAdd.Should().NotBeNull().And.HaveCount(3); } result.PropertiesToAdd.Should().ContainKey(MSBuildTaskHostRuntimeVersion); result.PropertiesToAdd[MSBuildTaskHostRuntimeVersion].Should().Be("mockRuntimeVersion"); @@ -586,10 +619,21 @@ private sealed class TestEnvironment : SdkResolverContext public string PathEnvironmentVariable { get; set; } - public string ProcessPath { get; set; } + public string ProcessPath + { + get + { + if (field is null) + { + throw new ArgumentException("ProcessPath must be set before accessing it, usually by CreateMuxerAndAddToPath()"); + } + return field; + } + set; + } public DirectoryInfo TestDirectory { get; } - public FileInfo VSSettingsFile { get; set; } + public FileInfo? VSSettingsFile { get; set; } public bool DisallowPrereleaseByDefault { get; set; } public TestEnvironment(TestAssetsManager testAssets, string identifier = "", [CallerMemberName] string callingMethod = "") @@ -618,11 +662,12 @@ public SdkResolver CreateResolver(bool useAmbientSettings = false) public DirectoryInfo GetProgramFilesDirectory(ProgramFiles programFiles) => new(Path.Combine(TestDirectory.FullName, $"ProgramFiles{programFiles}")); + /// the directory containing the MSBuild SDK you specified public DirectoryInfo CreateSdkDirectory( ProgramFiles programFiles, string sdkName, string sdkVersion, - Version minimumMSBuildVersion = null) + Version? minimumMSBuildVersion = null) { var netSdkDirectory = Path.Combine(TestDirectory.FullName, GetProgramFilesDirectory(programFiles).FullName, @@ -653,16 +698,62 @@ public DirectoryInfo CreateSdkDirectory( return sdkDir; } - public void CreateMuxerAndAddToPath(ProgramFiles programFiles) + /// A relative path within the test environment to create an SDK layout within + /// the directory containing the MSBuild SDK you specified + public DirectoryInfo CreateSdkDirectory( + string environmentLocalPath, + string sdkName, + string sdkVersion, + Version? minimumMSBuildVersion = null) { - var muxerDirectory = - new DirectoryInfo(Path.Combine( - TestDirectory.FullName, GetProgramFilesDirectory(programFiles).FullName, "dotnet")); + var netSdkDirectory = Path.Combine(TestDirectory.FullName, + environmentLocalPath, + "dotnet", + "sdk", + sdkVersion); + + new DirectoryInfo(netSdkDirectory).Create(); + + // hostfxr now checks for the existence of dotnet.dll in an SDK directory: https://github.com/dotnet/runtime/pull/89333 + // So create that file + var dotnetDllPath = Path.Combine(netSdkDirectory, "dotnet.dll"); + new FileInfo(dotnetDllPath).Create(); + + + var sdkDir = new DirectoryInfo(Path.Combine(netSdkDirectory, + "Sdks", + sdkName, + "Sdk")); + + sdkDir.Create(); + + if (minimumMSBuildVersion != null) + { + CreateMSBuildRequiredVersionFile(environmentLocalPath, sdkVersion, minimumMSBuildVersion); + } - ProcessPath = Path.Combine(muxerDirectory.FullName, Muxer); - new FileInfo(ProcessPath).Create(); + return sdkDir; + } + + /// If true, sets the ProcessPath and PathEnvironmentVariable properties. + /// The path to the newly-generated dotnet binary + public string CreateMuxerAndAddToPath(ProgramFiles programFiles, bool setEnvironmentProps = true) + { + var dotnetPath = CreateMuxer(GetProgramFilesDirectory(programFiles).FullName); + if (setEnvironmentProps) + { + ProcessPath = dotnetPath; + PathEnvironmentVariable = $"{Path.GetDirectoryName(dotnetPath)}{Path.PathSeparator}{PathEnvironmentVariable}"; + } + return dotnetPath; + } - PathEnvironmentVariable = $"{muxerDirectory}{Path.PathSeparator}{PathEnvironmentVariable}"; + public string CreateMuxer(string localRootWithinEnvironment) + { + var muxerDirectory = new DirectoryInfo(Path.Combine(TestDirectory.FullName, localRootWithinEnvironment, "dotnet")); + var dotnetPath = Path.Combine(muxerDirectory.FullName, Muxer); + new FileInfo(dotnetPath).Create(); + return dotnetPath; } private void CreateMSBuildRequiredVersionFile( @@ -687,16 +778,69 @@ private void CreateMSBuildRequiredVersionFile( minimumMSBuildVersion.ToString()); } - public void CreateGlobalJson(DirectoryInfo directory, string version) - => File.WriteAllText(Path.Combine(directory.FullName, "global.json"), - $@"{{ ""sdk"": {{ ""version"": ""{version}"" }} }}"); + /// A relative path within the test environment to create the required version file + private void CreateMSBuildRequiredVersionFile( + string environmentLocalPath, + string sdkVersion, + Version minimumMSBuildVersion) + { + if (minimumMSBuildVersion == null) + { + minimumMSBuildVersion = new Version(1, 0); + } + + var cliDirectory = new DirectoryInfo(Path.Combine( + TestDirectory.FullName, + environmentLocalPath, + "dotnet", + "sdk", + sdkVersion)); + + File.WriteAllText( + Path.Combine(cliDirectory.FullName, "minimumMSBuildVersion"), + minimumMSBuildVersion.ToString()); + } + + public void CreateGlobalJson(DirectoryInfo directory, string version, string[]? paths = null) + { + var builder = new StringBuilder(); + builder.AppendLine("{"); + builder.AppendLine("\t\"sdk\": {"); + builder.Append($"\t\"version\": \"{version}\""); + if (paths is not null) + { + builder.Append(','); + builder.AppendLine("\t\"paths\" : ["); + var first = true; + foreach (var path in paths) + { + if (!first) + { + builder.Append(','); + builder.AppendLine(); + } + builder.Append($"\t\t\"{path.Replace("\\", "\\\\")}\""); + if (first) + { + first = false; + } + } + builder.AppendLine("\t]"); + } + builder.AppendLine("\t}"); + builder.AppendLine("}"); + var globalJsonContent = builder.ToString(); + File.WriteAllText(Path.Combine(directory.FullName, "global.json"), globalJsonContent); + } - public string GetEnvironmentVariable(string variable) + public string? GetEnvironmentVariable(string variable) { switch (variable) { case "PATH": return PathEnvironmentVariable; + case "DOTNET_MSBUILD_SDK_RESOLVER_ENABLE_LOG": + return "true"; default: return null; } @@ -743,14 +887,14 @@ public void CreateVSSettingsFile(bool disallowPreviews) public void DeleteVSSettingsFile() { - VSSettingsFile.Delete(); + VSSettingsFile?.Delete(); } } private sealed class MockContext : SdkResolverContext { - public new string ProjectFilePath { get => base.ProjectFilePath; set => base.ProjectFilePath = value; } - public new string SolutionFilePath { get => base.SolutionFilePath; set => base.SolutionFilePath = value; } + public new string? ProjectFilePath { get => base.ProjectFilePath; set => base.ProjectFilePath = value; } + public new string? SolutionFilePath { get => base.SolutionFilePath; set => base.SolutionFilePath = value; } public new Version MSBuildVersion { get => base.MSBuildVersion; set => base.MSBuildVersion = value; } public new bool IsRunningInVisualStudio { get => base.IsRunningInVisualStudio; set => base.IsRunningInVisualStudio = value; } @@ -762,33 +906,33 @@ public DirectoryInfo ProjectFileDirectory public override SdkLogger Logger { get; protected set; } - public MockContext() + public MockContext(ITestOutputHelper? logger = null) { MSBuildVersion = new Version(15, 3, 0); - Logger = new MockLogger(); + Logger = new MockLogger(logger); } } private sealed class MockFactory : SdkResultFactory { - public override SdkResult IndicateFailure(IEnumerable errors, IEnumerable warnings = null) + public override SdkResult IndicateFailure(IEnumerable errors, IEnumerable? warnings = null) => new MockResult(success: false, path: null, version: null, warnings: warnings, errors: errors); - public override SdkResult IndicateSuccess(string path, string version, IEnumerable warnings = null) + public override SdkResult IndicateSuccess(string path, string? version, IEnumerable? warnings = null) => new MockResult(success: true, path: path, version: version, warnings: warnings); - public override SdkResult IndicateSuccess(string path, string version, IDictionary propertiesToAdd, IDictionary itemsToAdd, IEnumerable warnings = null) + public override SdkResult IndicateSuccess(string path, string? version, IDictionary? propertiesToAdd, IDictionary? itemsToAdd, IEnumerable? warnings = null) => new MockResult(success: true, path: path, version: version, warnings: warnings, propertiesToAdd: propertiesToAdd, itemsToAdd: itemsToAdd); - public override SdkResult IndicateSuccess(IEnumerable paths, string version, - IDictionary propertiesToAdd = null, IDictionary itemsToAdd = null, - IEnumerable warnings = null) => new MockResult(success: true, paths: paths, version: version, propertiesToAdd, itemsToAdd, warnings); + public override SdkResult IndicateSuccess(IEnumerable paths, string? version, + IDictionary? propertiesToAdd = null, IDictionary? itemsToAdd = null, + IEnumerable? warnings = null) => new MockResult(success: true, paths: paths, version: version, propertiesToAdd, itemsToAdd, warnings); } private sealed class MockResult : SdkResult { - public MockResult(bool success, string path, string version, IEnumerable warnings = null, - IEnumerable errors = null, IDictionary propertiesToAdd = null, IDictionary itemsToAdd = null) + public MockResult(bool success, string? path, string? version, IEnumerable? warnings = null, + IEnumerable? errors = null, IDictionary? propertiesToAdd = null, IDictionary? itemsToAdd = null) { Success = success; Path = path; @@ -799,8 +943,8 @@ public MockResult(bool success, string path, string version, IEnumerable ItemsToAdd = itemsToAdd; } - public MockResult(bool success, IEnumerable paths, string version, - IDictionary propertiesToAdd, IDictionary itemsToAdd, IEnumerable warnings) + public MockResult(bool success, IEnumerable? paths, string? version, + IDictionary? propertiesToAdd, IDictionary? itemsToAdd, IEnumerable? warnings) { Success = success; if (paths != null) @@ -822,20 +966,20 @@ public MockResult(bool success, IEnumerable paths, string version, } public override bool Success { get; protected set; } - public override string Version { get; protected set; } - public override string Path { get; protected set; } - public override IList AdditionalPaths { get; set; } - public override IDictionary PropertiesToAdd { get; protected set; } - public override IDictionary ItemsToAdd { get; protected set; } - public IEnumerable Errors { get; } - public IEnumerable Warnings { get; } + public override string? Version { get; protected set; } + public override string? Path { get; protected set; } + public override IList? AdditionalPaths { get; set; } + public override IDictionary? PropertiesToAdd { get; protected set; } + public override IDictionary? ItemsToAdd { get; protected set; } + public IEnumerable? Errors { get; } + public IEnumerable? Warnings { get; } } - private sealed class MockLogger : SdkLogger + private sealed class MockLogger(ITestOutputHelper? logger = null) : SdkLogger { public override void LogMessage(string message, MessageImportance messageImportance = MessageImportance.Low) { - + logger?.WriteLine($"{messageImportance}:\t{message}"); } } }