diff --git a/src/Microsoft.VisualStudio.ProjectSystem.Managed.VS/ProjectSystem/VS/Debug/ProjectLaunchTargetsProvider.cs b/src/Microsoft.VisualStudio.ProjectSystem.Managed.VS/ProjectSystem/VS/Debug/ProjectLaunchTargetsProvider.cs index 881df98aa2f..fa86638bcea 100644 --- a/src/Microsoft.VisualStudio.ProjectSystem.Managed.VS/ProjectSystem/VS/Debug/ProjectLaunchTargetsProvider.cs +++ b/src/Microsoft.VisualStudio.ProjectSystem.Managed.VS/ProjectSystem/VS/Debug/ProjectLaunchTargetsProvider.cs @@ -8,7 +8,6 @@ using Microsoft.VisualStudio.ProjectSystem.Debug; using Microsoft.VisualStudio.ProjectSystem.HotReload; using Microsoft.VisualStudio.ProjectSystem.Properties; -using Microsoft.VisualStudio.ProjectSystem.Utilities; using Microsoft.VisualStudio.ProjectSystem.VS.HotReload; using Microsoft.VisualStudio.Shell.Interop; using Newtonsoft.Json; @@ -36,7 +35,7 @@ internal class ProjectLaunchTargetsProvider : private readonly IUnconfiguredProjectVsServices _unconfiguredProjectVsServices; private readonly IDebugTokenReplacer _tokenReplacer; private readonly IFileSystem _fileSystem; - private readonly IEnvironmentHelper _environment; + private readonly IEnvironment _environment; private readonly IActiveDebugFrameworkServices _activeDebugFramework; private readonly IProjectThreadingService _threadingService; private readonly IVsUIService _debugger; @@ -50,7 +49,7 @@ public ProjectLaunchTargetsProvider( ConfiguredProject project, IDebugTokenReplacer tokenReplacer, IFileSystem fileSystem, - IEnvironmentHelper environment, + IEnvironment environment, IActiveDebugFrameworkServices activeDebugFramework, IOutputTypeChecker outputTypeChecker, IProjectThreadingService threadingService, diff --git a/src/Microsoft.VisualStudio.ProjectSystem.Managed.VS/ProjectSystem/VS/Retargeting/ProjectRetargetHandler.cs b/src/Microsoft.VisualStudio.ProjectSystem.Managed.VS/ProjectSystem/VS/Retargeting/ProjectRetargetHandler.cs index 5f2f2f24b1f..ac993faeb04 100644 --- a/src/Microsoft.VisualStudio.ProjectSystem.Managed.VS/ProjectSystem/VS/Retargeting/ProjectRetargetHandler.cs +++ b/src/Microsoft.VisualStudio.ProjectSystem.Managed.VS/ProjectSystem/VS/Retargeting/ProjectRetargetHandler.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. The .NET Foundation licenses this file to you under the MIT license. See the LICENSE.md file in the project root for more information. using System.Text.Json; +using Microsoft.VisualStudio.ProjectSystem.VS.Setup; using Microsoft.VisualStudio.Shell; using Microsoft.VisualStudio.Shell.Interop; using Microsoft.VisualStudio.Threading; @@ -18,6 +19,7 @@ internal sealed partial class ProjectRetargetHandler : IProjectRetargetHandler, private readonly IProjectThreadingService _projectThreadingService; private readonly IVsService _projectRetargetingService; private readonly IVsService _solutionService; + private readonly IDotNetEnvironment _dotnetEnvironment; private Guid _currentSdkDescriptionId = Guid.Empty; private Guid _sdkRetargetId = Guid.Empty; @@ -28,13 +30,15 @@ public ProjectRetargetHandler( IFileSystem fileSystem, IProjectThreadingService projectThreadingService, IVsService projectRetargetingService, - IVsService solutionService) + IVsService solutionService, + IDotNetEnvironment dotnetEnvironment) { _releasesProvider = releasesProvider; _fileSystem = fileSystem; _projectThreadingService = projectThreadingService; _projectRetargetingService = projectRetargetingService; _solutionService = solutionService; + _dotnetEnvironment = dotnetEnvironment; } public Task CheckForRetargetAsync(RetargetCheckOptions options) @@ -89,6 +93,12 @@ public Task RetargetAsync(TextWriter outputLogger, RetargetOptions options, IPro return null; } + // Check if the retarget is already installed globally + if (_dotnetEnvironment.IsSdkInstalled(retargetVersion)) + { + return null; + } + if (_currentSdkDescriptionId == Guid.Empty) { // register the current and retarget versions, note there is a bug in the current implementation @@ -142,7 +152,7 @@ public Task RetargetAsync(TextWriter outputLogger, RetargetOptions options, IPro { try { - using Stream stream = File.OpenRead(globalJsonPath); + using Stream stream = _fileSystem.OpenTextStream(globalJsonPath); using JsonDocument doc = await JsonDocument.ParseAsync(stream); if (doc.RootElement.TryGetProperty("sdk", out JsonElement sdkProp) && sdkProp.TryGetProperty("version", out JsonElement versionProp)) diff --git a/src/Microsoft.VisualStudio.ProjectSystem.Managed.VS/ProjectSystem/VS/Setup/DotNetEnvironment.cs b/src/Microsoft.VisualStudio.ProjectSystem.Managed.VS/ProjectSystem/VS/Setup/DotNetEnvironment.cs new file mode 100644 index 00000000000..2ac0ea51f53 --- /dev/null +++ b/src/Microsoft.VisualStudio.ProjectSystem.Managed.VS/ProjectSystem/VS/Setup/DotNetEnvironment.cs @@ -0,0 +1,123 @@ +// Licensed to the .NET Foundation under one or more agreements. The .NET Foundation licenses this file to you under the MIT license. See the LICENSE.md file in the project root for more information. + +using System.Runtime.InteropServices; +using IFileSystem = Microsoft.VisualStudio.IO.IFileSystem; +using IRegistry = Microsoft.VisualStudio.ProjectSystem.VS.Utilities.IRegistry; + +namespace Microsoft.VisualStudio.ProjectSystem.VS.Setup; + +/// +/// Provides information about the .NET environment and installed SDKs by querying the Windows registry. +/// +[Export(typeof(IDotNetEnvironment))] +internal class DotNetEnvironment : IDotNetEnvironment +{ + private readonly IFileSystem _fileSystem; + private readonly IRegistry _registry; + private readonly IEnvironment _environment; + + [ImportingConstructor] + public DotNetEnvironment(IFileSystem fileSystem, IRegistry registry, IEnvironment environment) + { + _fileSystem = fileSystem; + _registry = registry; + _environment = environment; + } + + /// + public bool IsSdkInstalled(string sdkVersion) + { + try + { + string archSubKey = GetArchitectureSubKey(_environment.ProcessArchitecture); + string registryKey = $@"SOFTWARE\dotnet\Setup\InstalledVersions\{archSubKey}\sdk"; + + // Get all value names from the sdk subkey + string[] installedVersions = _registry.GetValueNames( + Win32.RegistryHive.LocalMachine, + Win32.RegistryView.Registry32, + registryKey); + + // Check if the requested SDK version is in the list + foreach (string installedVersion in installedVersions) + { + if (string.Equals(installedVersion, sdkVersion, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + catch + { + // If we fail to check, assume the SDK is not installed + return false; + } + } + + /// + public string? GetDotNetHostPath() + { + // First check the registry + string archSubKey = GetArchitectureSubKey(_environment.ProcessArchitecture); + string registryKey = $@"SOFTWARE\dotnet\Setup\InstalledVersions\{archSubKey}"; + + string? installLocation = _registry.GetValue( + Win32.RegistryHive.LocalMachine, + Win32.RegistryView.Registry32, + registryKey, + "InstallLocation"); + + if (!string.IsNullOrEmpty(installLocation)) + { + string dotnetExePath = Path.Combine(installLocation, "dotnet.exe"); + if (_fileSystem.FileExists(dotnetExePath)) + { + return dotnetExePath; + } + } + + // Fallback to Program Files + string? programFiles = _environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); + if (programFiles is not null) + { + string dotnetPath = Path.Combine(programFiles, "dotnet", "dotnet.exe"); + + if (_fileSystem.FileExists(dotnetPath)) + { + return dotnetPath; + } + } + + return null; + } + + /// + public string[]? GetInstalledRuntimeVersions(Architecture architecture) + { + // https://github.com/dotnet/designs/blob/96d2ddad13dcb795ff2c5c6a051753363bdfcf7d/accepted/2020/install-locations.md#globally-registered-install-location-new + + string archSubKey = GetArchitectureSubKey(architecture); + string registryKey = $@"SOFTWARE\dotnet\Setup\InstalledVersions\{archSubKey}\sharedfx\Microsoft.NETCore.App"; + + string[] valueNames = _registry.GetValueNames( + Win32.RegistryHive.LocalMachine, + Win32.RegistryView.Registry32, + registryKey); + + return valueNames.Length == 0 ? null : valueNames; + } + + private static string GetArchitectureSubKey(Architecture architecture) + { + return architecture switch + { + Architecture.X86 => "x86", + Architecture.X64 => "x64", + Architecture.Arm => "arm", + Architecture.Arm64 => "arm64", + _ => architecture.ToString().ToLower() + }; + } +} diff --git a/src/Microsoft.VisualStudio.ProjectSystem.Managed.VS/ProjectSystem/VS/Setup/IDotNetEnvironment.cs b/src/Microsoft.VisualStudio.ProjectSystem.Managed.VS/ProjectSystem/VS/Setup/IDotNetEnvironment.cs new file mode 100644 index 00000000000..8a64011c09e --- /dev/null +++ b/src/Microsoft.VisualStudio.ProjectSystem.Managed.VS/ProjectSystem/VS/Setup/IDotNetEnvironment.cs @@ -0,0 +1,40 @@ +// Licensed to the .NET Foundation under one or more agreements. The .NET Foundation licenses this file to you under the MIT license. See the LICENSE.md file in the project root for more information. + +namespace Microsoft.VisualStudio.ProjectSystem.VS.Setup; + +/// +/// Provides information about the .NET environment and installed SDKs. +/// +[ProjectSystemContract(ProjectSystemContractScope.Global, ProjectSystemContractProvider.Private, Cardinality = ImportCardinality.ExactlyOne)] +internal interface IDotNetEnvironment +{ + /// + /// Checks if a specific .NET SDK version is installed on the system. + /// + /// The SDK version to check for (e.g., "8.0.415"). + /// + /// A task that represents the asynchronous operation. The task result contains + /// if the SDK version is installed; otherwise, . + /// + bool IsSdkInstalled(string sdkVersion); + + /// + /// Gets the path to the dotnet.exe executable. + /// + /// + /// The full path to dotnet.exe if found; otherwise, . + /// + string? GetDotNetHostPath(); + + /// + /// Reads the list of installed .NET Core runtimes for the specified architecture from the registry. + /// + /// + /// Returns runtimes installed both as standalone packages, and through VS Setup. + /// Values have the form 3.1.32, 7.0.11, 8.0.0-preview.7.23375.6, 8.0.0-rc.1.23419.4. + /// If results could not be determined, is returned. + /// + /// The runtime architecture to report results for. + /// An array of runtime versions, or if results could not be determined or no runtimes were found. + string[]? GetInstalledRuntimeVersions(System.Runtime.InteropServices.Architecture architecture); +} diff --git a/src/Microsoft.VisualStudio.ProjectSystem.Managed.VS/ProjectSystem/VS/Setup/NetCoreRuntimeVersionsRegistryReader.cs b/src/Microsoft.VisualStudio.ProjectSystem.Managed.VS/ProjectSystem/VS/Setup/NetCoreRuntimeVersionsRegistryReader.cs deleted file mode 100644 index 04b9e43c182..00000000000 --- a/src/Microsoft.VisualStudio.ProjectSystem.Managed.VS/ProjectSystem/VS/Setup/NetCoreRuntimeVersionsRegistryReader.cs +++ /dev/null @@ -1,37 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. The .NET Foundation licenses this file to you under the MIT license. See the LICENSE.md file in the project root for more information. - -using System.Runtime.InteropServices; -using Microsoft.Win32; - -namespace Microsoft.VisualStudio.ProjectSystem.VS.Setup; - -internal sealed class NetCoreRuntimeVersionsRegistryReader -{ - /// - /// Reads the list of installed .NET Core runtimes for the specified architecture, from the registry. - /// - /// - /// Returns runtimes installed both as standalone packages, and through VS Setup. - /// Values have the form 3.1.32, 7.0.11, 8.0.0-preview.7.23375.6, 8.0.0-rc.1.23419.4. - /// If results could not be determined, is returned. - /// - /// The runtime architecture to report results for. - /// An array of runtime versions, or if results could not be determined. - public static string[]? ReadRuntimeVersionsInstalledInLocalMachine(Architecture architecture) - { - // https://github.com/dotnet/designs/blob/96d2ddad13dcb795ff2c5c6a051753363bdfcf7d/accepted/2020/install-locations.md#globally-registered-install-location-new - - const string registryKeyPath = """SOFTWARE\dotnet\Setup\InstalledVersions\{0}\sharedfx\Microsoft.NETCore.App"""; - - using RegistryKey regKey = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry32); - using RegistryKey? subKey = regKey.OpenSubKey(string.Format(registryKeyPath, architecture.ToString().ToLower())); - - if (subKey is null) - { - System.Diagnostics.Debug.Fail("Failed to open registry sub key. This should never happen."); - return null; - } - - return subKey.GetValueNames(); - } -} diff --git a/src/Microsoft.VisualStudio.ProjectSystem.Managed.VS/ProjectSystem/VS/Setup/SetupComponentRegistrationService.cs b/src/Microsoft.VisualStudio.ProjectSystem.Managed.VS/ProjectSystem/VS/Setup/SetupComponentRegistrationService.cs index cb031725785..744b28e7b42 100644 --- a/src/Microsoft.VisualStudio.ProjectSystem.Managed.VS/ProjectSystem/VS/Setup/SetupComponentRegistrationService.cs +++ b/src/Microsoft.VisualStudio.ProjectSystem.Managed.VS/ProjectSystem/VS/Setup/SetupComponentRegistrationService.cs @@ -43,6 +43,7 @@ public SetupComponentRegistrationService( IVsService vsSetupCompositionService, ISolutionService solutionService, IProjectFaultHandlerService projectFaultHandlerService, + IDotNetEnvironment dotnetEnvironment, JoinableTaskContext joinableTaskContext) : base(new(joinableTaskContext)) { @@ -51,9 +52,9 @@ public SetupComponentRegistrationService( _solutionService = solutionService; _projectFaultHandlerService = projectFaultHandlerService; - _installedRuntimeComponentIds = new Lazy?>(FindInstalledRuntimeComponentIds); + _installedRuntimeComponentIds = new Lazy?>(() => FindInstalledRuntimeComponentIds(dotnetEnvironment)); - static HashSet? FindInstalledRuntimeComponentIds() + static HashSet? FindInstalledRuntimeComponentIds(IDotNetEnvironment dotnetEnvironment) { // Workaround for https://devdiv.visualstudio.com/DevDiv/_workitems/edit/1460328 // VS Setup doesn't know about runtimes installed outside of VS. Deep detection is not suggested for performance reasons. @@ -66,7 +67,7 @@ public SetupComponentRegistrationService( // TODO consider the architecture of the project itself Architecture architecture = RuntimeInformation.ProcessArchitecture; - string[]? runtimeVersions = NetCoreRuntimeVersionsRegistryReader.ReadRuntimeVersionsInstalledInLocalMachine(architecture); + string[]? runtimeVersions = dotnetEnvironment.GetInstalledRuntimeVersions(architecture); if (runtimeVersions is null) { diff --git a/src/Microsoft.VisualStudio.ProjectSystem.Managed.VS/ProjectSystem/VS/Utilities/IRegistry.cs b/src/Microsoft.VisualStudio.ProjectSystem.Managed.VS/ProjectSystem/VS/Utilities/IRegistry.cs new file mode 100644 index 00000000000..f4224369791 --- /dev/null +++ b/src/Microsoft.VisualStudio.ProjectSystem.Managed.VS/ProjectSystem/VS/Utilities/IRegistry.cs @@ -0,0 +1,35 @@ +// Licensed to the .NET Foundation under one or more agreements. The .NET Foundation licenses this file to you under the MIT license. See the LICENSE.md file in the project root for more information. + +using Microsoft.Win32; + +namespace Microsoft.VisualStudio.ProjectSystem.VS.Utilities; + +/// +/// Provides access to the Windows registry in a testable manner. +/// +[ProjectSystem.ProjectSystemContract(ProjectSystem.ProjectSystemContractScope.Global, ProjectSystem.ProjectSystemContractProvider.Private, Cardinality = ImportCardinality.ExactlyOne)] +internal interface IRegistry +{ + /// + /// Opens a registry key with the specified path under the given base key. + /// + /// The registry hive to open (e.g., LocalMachine, CurrentUser). + /// The registry view to use (e.g., Registry32, Registry64). + /// The path to the subkey to open. + /// The name of the value to retrieve. + /// + /// The registry key value as a string if found; otherwise, . + /// + string? GetValue(RegistryHive hive, RegistryView view, string subKeyPath, string valueName); + + /// + /// Gets the names of all values under the specified registry key. + /// + /// The registry hive to open (e.g., LocalMachine, CurrentUser). + /// The registry view to use (e.g., Registry32, Registry64). + /// The path to the subkey to open. + /// + /// An array of value names if the key exists; otherwise, an empty array. + /// + string[] GetValueNames(RegistryHive hive, RegistryView view, string subKeyPath); +} diff --git a/src/Microsoft.VisualStudio.ProjectSystem.Managed.VS/ProjectSystem/VS/Utilities/RegistryService.cs b/src/Microsoft.VisualStudio.ProjectSystem.Managed.VS/ProjectSystem/VS/Utilities/RegistryService.cs new file mode 100644 index 00000000000..adaff45ca2d --- /dev/null +++ b/src/Microsoft.VisualStudio.ProjectSystem.Managed.VS/ProjectSystem/VS/Utilities/RegistryService.cs @@ -0,0 +1,40 @@ +// Licensed to the .NET Foundation under one or more agreements. The .NET Foundation licenses this file to you under the MIT license. See the LICENSE.md file in the project root for more information. + +using Microsoft.Win32; + +namespace Microsoft.VisualStudio.ProjectSystem.VS.Utilities; + +/// +/// Provides access to the Windows registry. +/// +[Export(typeof(IRegistry))] +internal class RegistryService : IRegistry +{ + /// + public string? GetValue(RegistryHive hive, RegistryView view, string subKeyPath, string valueName) + { + using RegistryKey? subKey = OpenSubKey(hive, view, subKeyPath); + return subKey?.GetValue(valueName) as string; + } + + /// + public string[] GetValueNames(RegistryHive hive, RegistryView view, string subKeyPath) + { + using RegistryKey? subKey = OpenSubKey(hive, view, subKeyPath); + return subKey?.GetValueNames() ?? []; + } + + private static RegistryKey? OpenSubKey(RegistryHive hive, RegistryView view, string subKeyPath) + { + try + { + using RegistryKey baseKey = RegistryKey.OpenBaseKey(hive, view); + return baseKey.OpenSubKey(subKeyPath); + } + catch (Exception ex) when (ex.IsCatchable()) + { + // Return null on catchable registry access errors + return null; + } + } +} diff --git a/src/Microsoft.VisualStudio.ProjectSystem.Managed/ProjectSystem/Debug/DebugTokenReplacer.cs b/src/Microsoft.VisualStudio.ProjectSystem.Managed/ProjectSystem/Debug/DebugTokenReplacer.cs index 3711c5b5dc8..cac282a4716 100644 --- a/src/Microsoft.VisualStudio.ProjectSystem.Managed/ProjectSystem/Debug/DebugTokenReplacer.cs +++ b/src/Microsoft.VisualStudio.ProjectSystem.Managed/ProjectSystem/Debug/DebugTokenReplacer.cs @@ -1,7 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. The .NET Foundation licenses this file to you under the MIT license. See the LICENSE.md file in the project root for more information. using System.Text.RegularExpressions; -using Microsoft.VisualStudio.ProjectSystem.Utilities; namespace Microsoft.VisualStudio.ProjectSystem.Debug; @@ -12,14 +11,14 @@ internal sealed class DebugTokenReplacer : IDebugTokenReplacer // Regular expression string to extract $(sometoken) elements from a string private static readonly Regex s_matchTokenRegex = new(@"\$\((?[^\)]+)\)", RegexOptions.IgnoreCase); - private readonly IEnvironmentHelper _environmentHelper; + private readonly IEnvironment _environment; private readonly IActiveDebugFrameworkServices _activeDebugFrameworkService; private readonly IProjectAccessor _projectAccessor; [ImportingConstructor] - public DebugTokenReplacer(IEnvironmentHelper environmentHelper, IActiveDebugFrameworkServices activeDebugFrameworkService, IProjectAccessor projectAccessor) + public DebugTokenReplacer(IEnvironment environment, IActiveDebugFrameworkServices activeDebugFrameworkService, IProjectAccessor projectAccessor) { - _environmentHelper = environmentHelper; + _environment = environment; _activeDebugFrameworkService = activeDebugFrameworkService; _projectAccessor = projectAccessor; } @@ -37,7 +36,7 @@ public Task ReplaceTokensInStringAsync(string rawString, bool expandEnvi return Task.FromResult(rawString); string expandedString = expandEnvironmentVars - ? _environmentHelper.ExpandEnvironmentVariables(rawString) + ? _environment.ExpandEnvironmentVariables(rawString) : rawString; if (!s_matchTokenRegex.IsMatch(expandedString)) diff --git a/src/Microsoft.VisualStudio.ProjectSystem.Managed/ProjectSystem/Debug/ProjectAndExecutableLaunchHandlerHelpers.cs b/src/Microsoft.VisualStudio.ProjectSystem.Managed/ProjectSystem/Debug/ProjectAndExecutableLaunchHandlerHelpers.cs index c1e70ee5a0f..84b9aaa8843 100644 --- a/src/Microsoft.VisualStudio.ProjectSystem.Managed/ProjectSystem/Debug/ProjectAndExecutableLaunchHandlerHelpers.cs +++ b/src/Microsoft.VisualStudio.ProjectSystem.Managed/ProjectSystem/Debug/ProjectAndExecutableLaunchHandlerHelpers.cs @@ -2,7 +2,6 @@ using Microsoft.VisualStudio.IO; using Microsoft.VisualStudio.ProjectSystem.Properties; -using Microsoft.VisualStudio.ProjectSystem.Utilities; using Microsoft.VisualStudio.Text; namespace Microsoft.VisualStudio.ProjectSystem.Debug; @@ -69,9 +68,9 @@ public static async Task GetDefaultWorkingDirectoryAsync(ConfiguredProje /// Searches the path variable for the first match of . Returns /// if not found. /// - public static string? GetFullPathOfExeFromEnvironmentPath(string exeToSearchFor, IEnvironmentHelper environmentHelper, IFileSystem fileSystem) + public static string? GetFullPathOfExeFromEnvironmentPath(string exeToSearchFor, IEnvironment environment, IFileSystem fileSystem) { - string? pathEnv = environmentHelper.GetEnvironmentVariable("Path"); + string? pathEnv = environment.GetEnvironmentVariable("Path"); if (Strings.IsNullOrEmpty(pathEnv)) { @@ -118,7 +117,7 @@ public static async Task GetDefaultWorkingDirectoryAsync(ConfiguredProje /// /// /// - public static async Task GetRunCommandAsync(IProjectProperties properties, IEnvironmentHelper environment, IFileSystem fileSystem) + public static async Task GetRunCommandAsync(IProjectProperties properties, IEnvironment environment, IFileSystem fileSystem) { string runCommand = await properties.GetEvaluatedPropertyValueAsync("RunCommand"); @@ -153,7 +152,7 @@ public static async Task GetDefaultWorkingDirectoryAsync(ConfiguredProje /// public static async Task GetTargetCommandAsync( IProjectProperties properties, - IEnvironmentHelper environment, + IEnvironment environment, IFileSystem fileSystem, IOutputTypeChecker outputTypeChecker, bool validateSettings) @@ -189,7 +188,7 @@ public static async Task GetDefaultWorkingDirectoryAsync(ConfiguredProje /// public static async Task<(string ExeToRun, string Arguments, string WorkingDirectory)?> GetRunnableProjectInformationAsync( ConfiguredProject project, - IEnvironmentHelper environment, + IEnvironment environment, IFileSystem fileSystem, IOutputTypeChecker outputTypeChecker, bool validateSettings) diff --git a/src/Microsoft.VisualStudio.ProjectSystem.Managed/ProjectSystem/Utilities/EnvironmentHelper.cs b/src/Microsoft.VisualStudio.ProjectSystem.Managed/ProjectSystem/Utilities/EnvironmentService.cs similarity index 61% rename from src/Microsoft.VisualStudio.ProjectSystem.Managed/ProjectSystem/Utilities/EnvironmentHelper.cs rename to src/Microsoft.VisualStudio.ProjectSystem.Managed/ProjectSystem/Utilities/EnvironmentService.cs index 0dd3048d4b9..c94c52ec509 100644 --- a/src/Microsoft.VisualStudio.ProjectSystem.Managed/ProjectSystem/Utilities/EnvironmentHelper.cs +++ b/src/Microsoft.VisualStudio.ProjectSystem.Managed/ProjectSystem/Utilities/EnvironmentService.cs @@ -1,18 +1,32 @@ // Licensed to the .NET Foundation under one or more agreements. The .NET Foundation licenses this file to you under the MIT license. See the LICENSE.md file in the project root for more information. +using System.Runtime.InteropServices; + namespace Microsoft.VisualStudio.ProjectSystem.Utilities; /// -/// Wrapper over System.Environment abstraction for unit testing +/// Provides access to environment information. /// -[Export(typeof(IEnvironmentHelper))] -internal class EnvironmentHelper : IEnvironmentHelper +[Export(typeof(IEnvironment))] +internal class EnvironmentService : IEnvironment { + /// + public Architecture ProcessArchitecture => RuntimeInformation.ProcessArchitecture; + + /// + public string? GetFolderPath(Environment.SpecialFolder folder) + { + string path = Environment.GetFolderPath(folder); + return string.IsNullOrEmpty(path) ? null : path; + } + + /// public string? GetEnvironmentVariable(string name) { return Environment.GetEnvironmentVariable(name); } + /// public string ExpandEnvironmentVariables(string name) { if (name.IndexOf('%') == -1) diff --git a/src/Microsoft.VisualStudio.ProjectSystem.Managed/ProjectSystem/Utilities/IEnvironment.cs b/src/Microsoft.VisualStudio.ProjectSystem.Managed/ProjectSystem/Utilities/IEnvironment.cs new file mode 100644 index 00000000000..868d043fbf3 --- /dev/null +++ b/src/Microsoft.VisualStudio.ProjectSystem.Managed/ProjectSystem/Utilities/IEnvironment.cs @@ -0,0 +1,45 @@ +// Licensed to the .NET Foundation under one or more agreements. The .NET Foundation licenses this file to you under the MIT license. See the LICENSE.md file in the project root for more information. + +using System.Runtime.InteropServices; + +namespace Microsoft.VisualStudio.ProjectSystem; + +/// +/// Provides access to environment information in a testable manner. +/// +[ProjectSystemContract(ProjectSystemContractScope.Global, ProjectSystemContractProvider.Private, Cardinality = ImportCardinality.ExactlyOne)] +internal interface IEnvironment +{ + /// + /// Gets the process architecture for the currently running process. + /// + Architecture ProcessArchitecture { get; } + + /// + /// Gets the path to the system special folder that is identified by the specified enumeration. + /// + /// An enumerated constant that identifies a system special folder. + /// + /// The path to the specified system special folder, if that folder physically exists on your computer; + /// otherwise, null. + /// + string? GetFolderPath(Environment.SpecialFolder folder); + + /// + /// Retrieves the value of an environment variable from the current process. + /// + /// The name of the environment variable. + /// + /// The value of the environment variable specified by , or if the environment variable is not found. + /// + string? GetEnvironmentVariable(string name); + + /// + /// Replaces the name of each environment variable embedded in the specified string with the string equivalent of the value of the variable, then returns the resulting string. + /// + /// A string containing the names of zero or more environment variables. Each environment variable is quoted with the percent sign character (%). + /// + /// A string with each environment variable replaced by its value. + /// + string ExpandEnvironmentVariables(string name); +} diff --git a/src/Microsoft.VisualStudio.ProjectSystem.Managed/ProjectSystem/Utilities/IEnvironmentHelper.cs b/src/Microsoft.VisualStudio.ProjectSystem.Managed/ProjectSystem/Utilities/IEnvironmentHelper.cs deleted file mode 100644 index 06fab6ce267..00000000000 --- a/src/Microsoft.VisualStudio.ProjectSystem.Managed/ProjectSystem/Utilities/IEnvironmentHelper.cs +++ /dev/null @@ -1,14 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. The .NET Foundation licenses this file to you under the MIT license. See the LICENSE.md file in the project root for more information. - -namespace Microsoft.VisualStudio.ProjectSystem.Utilities; - -/// -/// Abstraction for System.Environment for unit testing -/// -[ProjectSystemContract(ProjectSystemContractScope.Global, ProjectSystemContractProvider.Private, Cardinality = ImportCardinality.ExactlyOne)] -internal interface IEnvironmentHelper -{ - string? GetEnvironmentVariable(string name); - - string ExpandEnvironmentVariables(string name); -} diff --git a/src/Microsoft.VisualStudio.ProjectSystem.Managed/Utilities/ExceptionExtensions.cs b/src/Microsoft.VisualStudio.ProjectSystem.Managed/Utilities/ExceptionExtensions.cs new file mode 100644 index 00000000000..0dae598276e --- /dev/null +++ b/src/Microsoft.VisualStudio.ProjectSystem.Managed/Utilities/ExceptionExtensions.cs @@ -0,0 +1,21 @@ +// Licensed to the .NET Foundation under one or more agreements. The .NET Foundation licenses this file to you under the MIT license. See the LICENSE.md file in the project root for more information. + +namespace Microsoft.VisualStudio.ProjectSystem; + +internal static class ExceptionExtensions +{ + /// + /// Gets whether this exception is of a type that is deemed to be catchable. + /// + /// + /// Certain types of exception should not be caught by catch blocks, as they represent states + /// from which program is not able to recover, such as a stack overflow, running out of memory, + /// the thread being aborted, or an attempt to read memory for which access is disallowed. + /// This helper is intended for use in exception filter expressions on catch blocks that wish to + /// catch all kinds of exceptions other than these uncatchable exception types. + /// + public static bool IsCatchable(this Exception e) + { + return e is not (StackOverflowException or OutOfMemoryException or ThreadAbortException or AccessViolationException); + } +} diff --git a/tests/Microsoft.VisualStudio.ProjectSystem.Managed.UnitTests/Mocks/IEnvironmentHelperFactory.cs b/tests/Microsoft.VisualStudio.ProjectSystem.Managed.UnitTests/Mocks/IEnvironmentHelperFactory.cs deleted file mode 100644 index 49447043ee4..00000000000 --- a/tests/Microsoft.VisualStudio.ProjectSystem.Managed.UnitTests/Mocks/IEnvironmentHelperFactory.cs +++ /dev/null @@ -1,16 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. The .NET Foundation licenses this file to you under the MIT license. See the LICENSE.md file in the project root for more information. - -namespace Microsoft.VisualStudio.ProjectSystem.Utilities; - -internal static class IEnvironmentHelperFactory -{ - public static IEnvironmentHelper ImplementGetEnvironmentVariable(string? result) - { - var mock = new Mock(); - - mock.Setup(s => s.GetEnvironmentVariable(It.IsAny())) - .Returns(() => result); - - return mock.Object; - } -} diff --git a/tests/Microsoft.VisualStudio.ProjectSystem.Managed.UnitTests/Mocks/IEnvironmentMock.cs b/tests/Microsoft.VisualStudio.ProjectSystem.Managed.UnitTests/Mocks/IEnvironmentMock.cs new file mode 100644 index 00000000000..f62fad90ceb --- /dev/null +++ b/tests/Microsoft.VisualStudio.ProjectSystem.Managed.UnitTests/Mocks/IEnvironmentMock.cs @@ -0,0 +1,69 @@ +// Licensed to the .NET Foundation under one or more agreements. The .NET Foundation licenses this file to you under the MIT license. See the LICENSE.md file in the project root for more information. + +using System.Runtime.InteropServices; + +namespace Microsoft.VisualStudio.ProjectSystem; + +/// +/// A mock implementation of for testing purposes. +/// +internal class IEnvironmentMock : AbstractMock +{ + private readonly Dictionary _specialFolders = new(); + private Architecture _processArchitecture = Architecture.X64; + + public IEnvironmentMock() + { + // Setup the mock to return values from our backing fields/dictionary + SetupGet(m => m.ProcessArchitecture).Returns(() => _processArchitecture); + Setup(m => m.GetFolderPath(It.IsAny())) + .Returns(folder => + { + if (_specialFolders.TryGetValue(folder, out string? path)) + { + return path; + } + return null; + }); + } + + /// + /// Gets or sets the process architecture for the currently running process. + /// + public Architecture ProcessArchitecture + { + get => _processArchitecture; + set => _processArchitecture = value; + } + + /// + /// Sets the path for a special folder. + /// + public IEnvironmentMock SetFolderPath(Environment.SpecialFolder folder, string path) + { + _specialFolders[folder] = path; + return this; + } + + /// + /// Sets the environment variable value to be returned for any name. + /// + /// The value to be returned. + /// + public IEnvironmentMock ImplementGetEnvironmentVariable(string value) + { + Setup(m => m.GetEnvironmentVariable(It.IsAny())).Returns(value); + return this; + } + + /// + /// Sets the environment variable value to be returned for any name. + /// + /// The callback to invoke to retrieve the value to be returned. + /// + public IEnvironmentMock ImplementExpandEnvironmentVariables(Func callback) + { + Setup(m => m.ExpandEnvironmentVariables(It.IsAny())).Returns((str) => callback(str)); + return this; + } +} diff --git a/tests/Microsoft.VisualStudio.ProjectSystem.Managed.UnitTests/ProjectSystem/Debug/DebugTokenReplacerTests.cs b/tests/Microsoft.VisualStudio.ProjectSystem.Managed.UnitTests/ProjectSystem/Debug/DebugTokenReplacerTests.cs index f3011eabafb..6ea25cf84f9 100644 --- a/tests/Microsoft.VisualStudio.ProjectSystem.Managed.UnitTests/ProjectSystem/Debug/DebugTokenReplacerTests.cs +++ b/tests/Microsoft.VisualStudio.ProjectSystem.Managed.UnitTests/ProjectSystem/Debug/DebugTokenReplacerTests.cs @@ -1,7 +1,5 @@ // Licensed to the .NET Foundation under one or more agreements. The .NET Foundation licenses this file to you under the MIT license. See the LICENSE.md file in the project root for more information. -using Microsoft.VisualStudio.ProjectSystem.Utilities; - namespace Microsoft.VisualStudio.ProjectSystem.Debug; public class DebugTokenReplacerTests @@ -13,12 +11,12 @@ public class DebugTokenReplacerTests { "%env3%", "$(msbuildProperty6)" } }; - private readonly Mock _envHelper; + private readonly IEnvironmentMock _environmentMock; public DebugTokenReplacerTests() { - _envHelper = new Mock(); - _envHelper.Setup(x => x.ExpandEnvironmentVariables(It.IsAny())).Returns((str) => + _environmentMock = new IEnvironmentMock(); + _environmentMock.ImplementExpandEnvironmentVariables((str) => { foreach ((string key, string value) in _envVars) { @@ -81,7 +79,7 @@ public async Task ReplaceTokensInStringTests(string? input, string? expected, bo private DebugTokenReplacer CreateInstance() { - var environmentHelper = _envHelper.Object; + var environmentHelper = _environmentMock.Object; var activeDebugFramework = new Mock(); activeDebugFramework.Setup(s => s.GetConfiguredProjectForActiveFrameworkAsync()) diff --git a/tests/Microsoft.VisualStudio.ProjectSystem.Managed.UnitTests/ProjectSystem/Debug/ProjectAndExecutableLaunchHandlerHelpersTests.cs b/tests/Microsoft.VisualStudio.ProjectSystem.Managed.UnitTests/ProjectSystem/Debug/ProjectAndExecutableLaunchHandlerHelpersTests.cs index 4b1fc06af2a..3f15b25bc4e 100644 --- a/tests/Microsoft.VisualStudio.ProjectSystem.Managed.UnitTests/ProjectSystem/Debug/ProjectAndExecutableLaunchHandlerHelpersTests.cs +++ b/tests/Microsoft.VisualStudio.ProjectSystem.Managed.UnitTests/ProjectSystem/Debug/ProjectAndExecutableLaunchHandlerHelpersTests.cs @@ -2,7 +2,6 @@ using System.Runtime.InteropServices; using Microsoft.VisualStudio.IO; -using Microsoft.VisualStudio.ProjectSystem.Utilities; namespace Microsoft.VisualStudio.ProjectSystem.Debug; @@ -93,7 +92,7 @@ public void GetFullPathOfExeFromEnvironmentPath_Returns_FullPath_If_Exists() var exeFullPath = @"C:\ExeName.exe"; var path = @"C:\Windows\System32;C:\"; var fileSystem = new IFileSystemMock(); - var environment = IEnvironmentHelperFactory.ImplementGetEnvironmentVariable(path); + var environment = new IEnvironmentMock().ImplementGetEnvironmentVariable(path).Object; // Act fileSystem.AddFile(exeFullPath); @@ -110,7 +109,7 @@ public void GetFullPathOfExeFromEnvironmentPath_Returns_Null_If_Not_Exists() var exeName = "ExeName.exe"; var path = @"C:\Windows\System32;C:\"; var fileSystem = new IFileSystemMock(); - var environment = IEnvironmentHelperFactory.ImplementGetEnvironmentVariable(path); + var environment = new IEnvironmentMock().ImplementGetEnvironmentVariable(path).Object; // Act var fullPathOfExeFromEnvironmentPath = ProjectAndExecutableLaunchHandlerHelpers.GetFullPathOfExeFromEnvironmentPath(exeName, environment, fileSystem); diff --git a/tests/Microsoft.VisualStudio.ProjectSystem.Managed.UnitTests/Utilities/EnvironmentServiceTests.cs b/tests/Microsoft.VisualStudio.ProjectSystem.Managed.UnitTests/Utilities/EnvironmentServiceTests.cs new file mode 100644 index 00000000000..4883fbdbf59 --- /dev/null +++ b/tests/Microsoft.VisualStudio.ProjectSystem.Managed.UnitTests/Utilities/EnvironmentServiceTests.cs @@ -0,0 +1,176 @@ +// Licensed to the .NET Foundation under one or more agreements. The .NET Foundation licenses this file to you under the MIT license. See the LICENSE.md file in the project root for more information. + +using Microsoft.VisualStudio.ProjectSystem.Utilities; + +namespace Microsoft.VisualStudio.Utilities; + +public class EnvironmentServiceTests +{ + [Theory] + [InlineData(Environment.SpecialFolder.ProgramFiles)] + [InlineData(Environment.SpecialFolder.ApplicationData)] + [InlineData(Environment.SpecialFolder.CommonApplicationData)] + [InlineData(Environment.SpecialFolder.System)] + public void GetFolderPath_ReturnsSystemValue(Environment.SpecialFolder folder) + { + var service = new EnvironmentService(); + + string? result = service.GetFolderPath(folder); + string expected = Environment.GetFolderPath(folder); + + Assert.Equal(string.IsNullOrEmpty(expected) ? null : expected, result); + } + + [Fact] + public void GetEnvironmentVariable_WhenVariableExists_ReturnsValue() + { + var service = new EnvironmentService(); + + // PATH should exist on all systems + string? result = service.GetEnvironmentVariable("PATH"); + + Assert.NotNull(result); + Assert.Equal(Environment.GetEnvironmentVariable("PATH"), result); + } + + [Fact] + public void GetEnvironmentVariable_WhenVariableDoesNotExist_ReturnsNull() + { + var service = new EnvironmentService(); + + // Use a GUID to ensure the variable doesn't exist + string nonExistentVar = $"NON_EXISTENT_VAR_{Guid.NewGuid():N}"; + + string? result = service.GetEnvironmentVariable(nonExistentVar); + + Assert.Null(result); + } + + [Fact] + public void GetEnvironmentVariable_WithCommonSystemVariables_ReturnsExpectedValues() + { + var service = new EnvironmentService(); + + // Test common system variables that should exist + string[] variables = { "PATH", "TEMP", "TMP" }; + + foreach (string varName in variables) + { + string? result = service.GetEnvironmentVariable(varName); + string? expected = Environment.GetEnvironmentVariable(varName); + + Assert.Equal(expected, result); + } + } + + [Fact] + public void ExpandEnvironmentVariables_WithNoVariables_ReturnsSameString() + { + var service = new EnvironmentService(); + string input = "C:\\Some\\Path\\Without\\Variables"; + + string result = service.ExpandEnvironmentVariables(input); + + Assert.Equal(input, result); + Assert.Same(input, result); // Should return the same instance for performance + } + + [Fact] + public void ExpandEnvironmentVariables_WithVariable_ExpandsCorrectly() + { + var service = new EnvironmentService(); + + // Set a test environment variable + string testVarName = $"TEST_VAR_{Guid.NewGuid():N}"; + string testVarValue = "TestValue123"; + Environment.SetEnvironmentVariable(testVarName, testVarValue); + + try + { + string input = $"Before %{testVarName}% After"; + string result = service.ExpandEnvironmentVariables(input); + + Assert.Equal($"Before {testVarValue} After", result); + } + finally + { + // Clean up + Environment.SetEnvironmentVariable(testVarName, null); + } + } + + [Fact] + public void ExpandEnvironmentVariables_WithMultipleVariables_ExpandsAll() + { + var service = new EnvironmentService(); + + // Set test environment variables + string testVar1 = $"TEST_VAR1_{Guid.NewGuid():N}"; + string testVar2 = $"TEST_VAR2_{Guid.NewGuid():N}"; + Environment.SetEnvironmentVariable(testVar1, "Value1"); + Environment.SetEnvironmentVariable(testVar2, "Value2"); + + try + { + string input = $"%{testVar1}%\\Path\\%{testVar2}%"; + string result = service.ExpandEnvironmentVariables(input); + + Assert.Equal("Value1\\Path\\Value2", result); + } + finally + { + // Clean up + Environment.SetEnvironmentVariable(testVar1, null); + Environment.SetEnvironmentVariable(testVar2, null); + } + } + + [Fact] + public void ExpandEnvironmentVariables_WithSystemVariable_ExpandsCorrectly() + { + var service = new EnvironmentService(); + string input = "%TEMP%\\subfolder"; + + string result = service.ExpandEnvironmentVariables(input); + string expected = Environment.ExpandEnvironmentVariables(input); + + Assert.Equal(expected, result); + } + + [Fact] + public void ExpandEnvironmentVariables_WithNonExistentVariable_LeavesUnexpanded() + { + var service = new EnvironmentService(); + string nonExistentVar = $"NON_EXISTENT_{Guid.NewGuid():N}"; + string input = $"%{nonExistentVar}%"; + + string result = service.ExpandEnvironmentVariables(input); + string expected = Environment.ExpandEnvironmentVariables(input); + + Assert.Equal(expected, result); + } + + [Fact] + public void ExpandEnvironmentVariables_WithEmptyString_ReturnsEmptyString() + { + var service = new EnvironmentService(); + string input = string.Empty; + + string result = service.ExpandEnvironmentVariables(input); + + Assert.Equal(string.Empty, result); + Assert.Same(input, result); // Should return the same instance + } + + [Fact] + public void ExpandEnvironmentVariables_WithOnlyText_ReturnsOriginal() + { + var service = new EnvironmentService(); + string input = "No environment variables here"; + + string result = service.ExpandEnvironmentVariables(input); + + Assert.Equal(input, result); + Assert.Same(input, result); // Performance optimization check + } +} diff --git a/tests/Microsoft.VisualStudio.ProjectSystem.Managed.VS.UnitTests/Microsoft.VisualStudio.ProjectSystem.Managed.VS.UnitTests.csproj b/tests/Microsoft.VisualStudio.ProjectSystem.Managed.VS.UnitTests/Microsoft.VisualStudio.ProjectSystem.Managed.VS.UnitTests.csproj index 03245039253..26ca6c1650d 100644 --- a/tests/Microsoft.VisualStudio.ProjectSystem.Managed.VS.UnitTests/Microsoft.VisualStudio.ProjectSystem.Managed.VS.UnitTests.csproj +++ b/tests/Microsoft.VisualStudio.ProjectSystem.Managed.VS.UnitTests/Microsoft.VisualStudio.ProjectSystem.Managed.VS.UnitTests.csproj @@ -18,6 +18,8 @@ + + \ No newline at end of file diff --git a/tests/Microsoft.VisualStudio.ProjectSystem.Managed.VS.UnitTests/Mocks/IRegistryMock.cs b/tests/Microsoft.VisualStudio.ProjectSystem.Managed.VS.UnitTests/Mocks/IRegistryMock.cs new file mode 100644 index 00000000000..9d8c64cb2a4 --- /dev/null +++ b/tests/Microsoft.VisualStudio.ProjectSystem.Managed.VS.UnitTests/Mocks/IRegistryMock.cs @@ -0,0 +1,67 @@ +// Licensed to the .NET Foundation under one or more agreements. The .NET Foundation licenses this file to you under the MIT license. See the LICENSE.md file in the project root for more information. + +using Microsoft.Win32; + +namespace Microsoft.VisualStudio.ProjectSystem.VS.Utilities; + +/// +/// A mock implementation of for testing purposes. +/// Use to configure registry values that should be returned by the mock. +/// +internal class IRegistryMock : AbstractMock +{ + private readonly Dictionary _registryData = new(StringComparer.OrdinalIgnoreCase); + + public IRegistryMock() + { + // Setup the mock to return values from our backing dictionary + Setup(m => m.GetValue(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((hive, view, subKeyPath, valueName) => + { + string key = BuildKey(hive, view, subKeyPath, valueName); + if (_registryData.TryGetValue(key, out string? value)) + { + return value; + } + return null; + }); + + Setup(m => m.GetValueNames(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((hive, view, subKeyPath) => + { + string keyPrefix = BuildKeyPrefix(hive, view, subKeyPath); + var valueNames = new List(); + + foreach (var key in _registryData.Keys) + { + if (key.StartsWith(keyPrefix, StringComparison.OrdinalIgnoreCase)) + { + // Extract the value name from the key + string valueName = key.Substring(keyPrefix.Length); + valueNames.Add(valueName); + } + } + + return valueNames.ToArray(); + }); + } + + /// + /// Sets a registry value for the mock to return. + /// + public void SetValue(RegistryHive hive, RegistryView view, string subKeyPath, string valueName, string value) + { + string key = BuildKey(hive, view, subKeyPath, valueName); + _registryData[key] = value; + } + + private static string BuildKey(RegistryHive hive, RegistryView view, string subKeyPath, string valueName) + { + return $"{hive}\\{view}\\{subKeyPath}\\{valueName}"; + } + + private static string BuildKeyPrefix(RegistryHive hive, RegistryView view, string subKeyPath) + { + return $"{hive}\\{view}\\{subKeyPath}\\"; + } +} diff --git a/tests/Microsoft.VisualStudio.ProjectSystem.Managed.VS.UnitTests/ProjectSystem/VS/Debug/ProjectLaunchTargetsProviderTests.cs b/tests/Microsoft.VisualStudio.ProjectSystem.Managed.VS.UnitTests/ProjectSystem/VS/Debug/ProjectLaunchTargetsProviderTests.cs index 81a011f6dc0..c3922b181a9 100644 --- a/tests/Microsoft.VisualStudio.ProjectSystem.Managed.VS.UnitTests/ProjectSystem/VS/Debug/ProjectLaunchTargetsProviderTests.cs +++ b/tests/Microsoft.VisualStudio.ProjectSystem.Managed.VS.UnitTests/ProjectSystem/VS/Debug/ProjectLaunchTargetsProviderTests.cs @@ -4,7 +4,6 @@ using Microsoft.VisualStudio.IO; using Microsoft.VisualStudio.ProjectSystem.Debug; using Microsoft.VisualStudio.ProjectSystem.HotReload; -using Microsoft.VisualStudio.ProjectSystem.Utilities; using Microsoft.VisualStudio.ProjectSystem.VS.HotReload; using Microsoft.VisualStudio.Shell.Interop; @@ -1120,7 +1119,7 @@ private ProjectLaunchTargetsProvider GetDebugTargetsProvider( o.UnconfiguredProject == project && o.Services == configuredProjectServices && o.Capabilities == capabilitiesScope); - var environment = IEnvironmentHelperFactory.ImplementGetEnvironmentVariable(_Path); + var environment = new IEnvironmentMock().ImplementGetEnvironmentVariable(_Path).Object; return CreateInstance( configuredProject: configuredProject, @@ -1135,14 +1134,14 @@ private static ProjectLaunchTargetsProvider CreateInstance( ConfiguredProject? configuredProject = null, IDebugTokenReplacer? tokenReplacer = null, IFileSystem? fileSystem = null, - IEnvironmentHelper? environment = null, + IEnvironment? environment = null, IActiveDebugFrameworkServices? activeDebugFramework = null, IOutputTypeChecker? typeChecker = null, IProjectThreadingService? threadingService = null, IVsDebugger10? debugger = null, IHotReloadOptionService? hotReloadSettings = null) { - environment ??= Mock.Of(); + environment ??= new IEnvironmentMock().Object; tokenReplacer ??= IDebugTokenReplacerFactory.Create(); activeDebugFramework ??= IActiveDebugFrameworkServicesFactory.ImplementGetConfiguredProjectForActiveFrameworkAsync(configuredProject); threadingService ??= IProjectThreadingServiceFactory.Create(); diff --git a/tests/Microsoft.VisualStudio.ProjectSystem.Managed.VS.UnitTests/ProjectSystem/VS/Retargeting/ProjectRetargetHandlerTests.cs b/tests/Microsoft.VisualStudio.ProjectSystem.Managed.VS.UnitTests/ProjectSystem/VS/Retargeting/ProjectRetargetHandlerTests.cs new file mode 100644 index 00000000000..3d216d8d511 --- /dev/null +++ b/tests/Microsoft.VisualStudio.ProjectSystem.Managed.VS.UnitTests/ProjectSystem/VS/Retargeting/ProjectRetargetHandlerTests.cs @@ -0,0 +1,432 @@ +// Licensed to the .NET Foundation under one or more agreements. The .NET Foundation licenses this file to you under the MIT license. See the LICENSE.md file in the project root for more information. + +using Microsoft.VisualStudio.IO; +using Microsoft.VisualStudio.ProjectSystem.VS.Setup; +using Microsoft.VisualStudio.Shell.Interop; + +namespace Microsoft.VisualStudio.ProjectSystem.VS.Retargeting; + +public class ProjectRetargetHandlerTests +{ + private const string GlobalJsonWithSdk = """ + { + "sdk": { + "version": "8.0.100" + } + } + """; + + [Fact] + public async Task CheckForRetargetAsync_WhenNoValidOptions_ReturnsNull() + { + var handler = CreateInstance(); + + var result = await handler.CheckForRetargetAsync(RetargetCheckOptions.None); + + Assert.Null(result); + } + + [Fact] + public async Task CheckForRetargetAsync_WhenRetargetingServiceIsNull_ReturnsNull() + { + var handler = CreateInstance(trackProjectRetargeting: null); + + var result = await handler.CheckForRetargetAsync(RetargetCheckOptions.ProjectLoad); + + Assert.Null(result); + } + + [Fact] + public async Task CheckForRetargetAsync_WhenNoGlobalJson_ReturnsNull() + { + var fileSystem = new IFileSystemMock(); + var solution = CreateSolutionWithDirectory(@"C:\Solution"); + + var handler = CreateInstance(fileSystem: fileSystem, solution: solution); + + var result = await handler.CheckForRetargetAsync(RetargetCheckOptions.ProjectLoad); + + Assert.Null(result); + } + + [Fact] + public async Task CheckForRetargetAsync_WhenGlobalJsonHasNoSdkVersion_ReturnsNull() + { + var fileSystem = new IFileSystemMock(); + var solution = CreateSolutionWithDirectory(@"C:\Solution"); + + // Create global.json without sdk.version + string globalJsonPath = @"C:\Solution\global.json"; + await fileSystem.WriteAllTextAsync(globalJsonPath, "{}"); + + var handler = CreateInstance(fileSystem: fileSystem, solution: solution); + + var result = await handler.CheckForRetargetAsync(RetargetCheckOptions.ProjectLoad); + + Assert.Null(result); + } + + [Fact] + public async Task CheckForRetargetAsync_WhenNoRetargetVersionAvailable_ReturnsNull() + { + var fileSystem = new IFileSystemMock(); + var solution = CreateSolutionWithDirectory(@"C:\Solution"); + + // Create global.json with sdk version + string globalJsonPath = @"C:\Solution\global.json"; + await fileSystem.WriteAllTextAsync(globalJsonPath, GlobalJsonWithSdk); + + var releasesProvider = Mock.Of( + p => p.GetSupportedOrLatestSdkVersionAsync("8.0.100", true, default) == Task.FromResult(null)); + + var handler = CreateInstance( + fileSystem: fileSystem, + solution: solution, + releasesProvider: releasesProvider); + + var result = await handler.CheckForRetargetAsync(RetargetCheckOptions.ProjectLoad); + + Assert.Null(result); + } + + [Fact] + public async Task CheckForRetargetAsync_WhenRetargetVersionSameAsCurrent_ReturnsNull() + { + var fileSystem = new IFileSystemMock(); + var solution = CreateSolutionWithDirectory(@"C:\Solution"); + + string globalJsonPath = @"C:\Solution\global.json"; + await fileSystem.WriteAllTextAsync(globalJsonPath, GlobalJsonWithSdk); + + // Releases provider returns same version + var releasesProvider = Mock.Of( + p => p.GetSupportedOrLatestSdkVersionAsync("8.0.100", true, default) == Task.FromResult("8.0.100")); + + var handler = CreateInstance( + fileSystem: fileSystem, + solution: solution, + releasesProvider: releasesProvider); + + var result = await handler.CheckForRetargetAsync(RetargetCheckOptions.ProjectLoad); + + Assert.Null(result); + } + + [Fact] + public async Task CheckForRetargetAsync_WhenRetargetVersionIsInstalled_ReturnsNull() + { + var fileSystem = new IFileSystemMock(); + var solution = CreateSolutionWithDirectory(@"C:\Solution"); + + string globalJsonPath = @"C:\Solution\global.json"; + await fileSystem.WriteAllTextAsync(globalJsonPath, GlobalJsonWithSdk); + + var releasesProvider = Mock.Of( + p => p.GetSupportedOrLatestSdkVersionAsync("8.0.100", true, default) == Task.FromResult("8.0.200")); + + // SDK is already installed + var dotnetEnvironment = Mock.Of( + s => s.IsSdkInstalled("8.0.200") == true); + + var handler = CreateInstance( + fileSystem: fileSystem, + solution: solution, + releasesProvider: releasesProvider, + dotnetEnvironment: dotnetEnvironment); + + var result = await handler.CheckForRetargetAsync(RetargetCheckOptions.ProjectLoad); + + Assert.Null(result); + } + + [Fact] + public async Task CheckForRetargetAsync_WhenRetargetVersionNotInstalled_ReturnsTargetChange() + { + var fileSystem = new IFileSystemMock(); + var solution = CreateSolutionWithDirectory(@"C:\Solution"); + + string globalJsonPath = @"C:\Solution\global.json"; + await fileSystem.WriteAllTextAsync(globalJsonPath, GlobalJsonWithSdk); + + var releasesProvider = Mock.Of( + p => p.GetSupportedOrLatestSdkVersionAsync("8.0.100", true, default) == Task.FromResult("8.0.200")); + + // SDK is NOT installed + var dotnetEnvironment = Mock.Of( + s => s.IsSdkInstalled("8.0.200") == false); + + var retargetingService = new Mock(); + retargetingService.Setup(r => r.RegisterProjectTarget(It.IsAny())) + .Returns(HResult.OK); + + var handler = CreateInstance( + fileSystem: fileSystem, + solution: solution, + releasesProvider: releasesProvider, + dotnetEnvironment: dotnetEnvironment, + trackProjectRetargeting: retargetingService.Object); + + var result = await handler.CheckForRetargetAsync(RetargetCheckOptions.ProjectLoad); + + Assert.NotNull(result); + Assert.IsType(result); + } + + [Theory] + [InlineData(RetargetCheckOptions.ProjectRetarget)] + [InlineData(RetargetCheckOptions.SolutionRetarget)] + [InlineData(RetargetCheckOptions.ProjectLoad)] + [InlineData(RetargetCheckOptions.ProjectRetarget | RetargetCheckOptions.SolutionRetarget)] + public async Task CheckForRetargetAsync_WithValidOptions_CallsGetTargetChange(RetargetCheckOptions options) + { + var fileSystem = new IFileSystemMock(); + var solution = CreateSolutionWithDirectory(@"C:\Solution"); + + string globalJsonPath = @"C:\Solution\global.json"; + await fileSystem.WriteAllTextAsync(globalJsonPath, GlobalJsonWithSdk); + + var releasesProvider = Mock.Of( + p => p.GetSupportedOrLatestSdkVersionAsync("8.0.100", true, default) == Task.FromResult("8.0.200")); + + var dotnetEnvironment = Mock.Of( + s => s.IsSdkInstalled("8.0.200") == false); + + var retargetingService = new Mock(); + retargetingService.Setup(r => r.RegisterProjectTarget(It.IsAny())) + .Returns(HResult.OK); + + var handler = CreateInstance( + fileSystem: fileSystem, + solution: solution, + releasesProvider: releasesProvider, + dotnetEnvironment: dotnetEnvironment, + trackProjectRetargeting: retargetingService.Object); + + var result = await handler.CheckForRetargetAsync(options); + + // Should get a result for valid options + Assert.NotNull(result); + } + + [Fact] + public async Task CheckForRetargetAsync_FindsGlobalJsonInParentDirectory() + { + var fileSystem = new IFileSystemMock(); + var solution = CreateSolutionWithDirectory(@"C:\Solution\SubFolder"); + + // Create global.json in parent directory + string globalJsonPath = @"C:\Solution\global.json"; + await fileSystem.WriteAllTextAsync(globalJsonPath, GlobalJsonWithSdk); + + var releasesProvider = Mock.Of( + p => p.GetSupportedOrLatestSdkVersionAsync("8.0.100", true, default) == Task.FromResult("8.0.200")); + + var dotnetEnvironment = Mock.Of( + s => s.IsSdkInstalled("8.0.200") == false); + + var retargetingService = new Mock(); + retargetingService.Setup(r => r.RegisterProjectTarget(It.IsAny())) + .Returns(HResult.OK); + + var handler = CreateInstance( + fileSystem: fileSystem, + solution: solution, + releasesProvider: releasesProvider, + dotnetEnvironment: dotnetEnvironment, + trackProjectRetargeting: retargetingService.Object); + + var result = await handler.CheckForRetargetAsync(RetargetCheckOptions.ProjectLoad); + + Assert.NotNull(result); + } + + [Fact] + public async Task CheckForRetargetAsync_RegistersTargetDescriptions() + { + var fileSystem = new IFileSystemMock(); + var solution = CreateSolutionWithDirectory(@"C:\Solution"); + + string globalJsonPath = @"C:\Solution\global.json"; + await fileSystem.WriteAllTextAsync(globalJsonPath, GlobalJsonWithSdk); + + var releasesProvider = Mock.Of( + p => p.GetSupportedOrLatestSdkVersionAsync("8.0.100", true, default) == Task.FromResult("8.0.200")); + + var dotnetEnvironment = Mock.Of( + s => s.IsSdkInstalled("8.0.200") == false); + + var retargetingService = new Mock(); + retargetingService.Setup(r => r.RegisterProjectTarget(It.IsAny())) + .Returns(HResult.OK); + + var handler = CreateInstance( + fileSystem: fileSystem, + solution: solution, + releasesProvider: releasesProvider, + dotnetEnvironment: dotnetEnvironment, + trackProjectRetargeting: retargetingService.Object); + + var result = await handler.CheckForRetargetAsync(RetargetCheckOptions.ProjectLoad); + + Assert.NotNull(result); + // Verify RegisterProjectTarget was called twice (workaround for bug) + retargetingService.Verify(r => r.RegisterProjectTarget(It.IsAny()), Times.Exactly(2)); + } + + [Fact] + public async Task GetAffectedFilesAsync_ReturnsEmptyList() + { + var handler = CreateInstance(); + + var result = await handler.GetAffectedFilesAsync(null!); + + Assert.NotNull(result); + Assert.Empty(result); + } + + [Fact] + public async Task RetargetAsync_ReturnsCompletedTask() + { + var handler = CreateInstance(); + + await handler.RetargetAsync(TextWriter.Null, RetargetOptions.None, null!, string.Empty); + + // Should complete without throwing + } + + [Fact] + public void Dispose_WhenNoTargetsRegistered_DoesNotThrow() + { + var handler = CreateInstance(); + + handler.Dispose(); + + // Should complete without throwing + } + + [Fact] + public async Task Dispose_WhenTargetsRegistered_UnregistersTargets() + { + var fileSystem = new IFileSystemMock(); + var solution = CreateSolutionWithDirectory(@"C:\Solution"); + + string globalJsonPath = @"C:\Solution\global.json"; + await fileSystem.WriteAllTextAsync(globalJsonPath, GlobalJsonWithSdk); + + var releasesProvider = Mock.Of( + p => p.GetSupportedOrLatestSdkVersionAsync("8.0.100", true, default) == Task.FromResult("8.0.200")); + + var dotnetEnvironment = Mock.Of( + s => s.IsSdkInstalled("8.0.200") == false); + + var retargetingService = new Mock(); + retargetingService.Setup(r => r.RegisterProjectTarget(It.IsAny())) + .Returns(HResult.OK); + retargetingService.Setup(r => r.UnregisterProjectTarget(It.IsAny())) + .Returns(HResult.OK); + + var handler = CreateInstance( + fileSystem: fileSystem, + solution: solution, + releasesProvider: releasesProvider, + dotnetEnvironment: dotnetEnvironment, + trackProjectRetargeting: retargetingService.Object); + + // Register targets + await handler.CheckForRetargetAsync(RetargetCheckOptions.ProjectLoad); + + // Dispose + handler.Dispose(); + + // Verify UnregisterProjectTarget was called twice (once for each registered target) + retargetingService.Verify(r => r.UnregisterProjectTarget(It.IsAny()), Times.Exactly(2)); + } + + [Fact] + public async Task CheckForRetargetAsync_WithInvalidGlobalJson_ReturnsNull() + { + var fileSystem = new IFileSystemMock(); + var solution = CreateSolutionWithDirectory(@"C:\Solution"); + + // Create invalid JSON + string globalJsonPath = @"C:\Solution\global.json"; + await fileSystem.WriteAllTextAsync(globalJsonPath, "{ invalid json }"); + + var handler = CreateInstance(fileSystem: fileSystem, solution: solution); + + var result = await handler.CheckForRetargetAsync(RetargetCheckOptions.ProjectLoad); + + Assert.Null(result); + } + + [Fact] + public async Task CheckForRetargetAsync_CallsReleasesProviderWithCorrectParameters() + { + var fileSystem = new IFileSystemMock(); + var solution = CreateSolutionWithDirectory(@"C:\Solution"); + + string globalJsonPath = @"C:\Solution\global.json"; + await fileSystem.WriteAllTextAsync(globalJsonPath, GlobalJsonWithSdk); + + var mockReleasesProvider = new Mock(); + mockReleasesProvider + .Setup(p => p.GetSupportedOrLatestSdkVersionAsync("8.0.100", true, default)) + .ReturnsAsync((string?)null); + + var retargetingService = new Mock(); + + var handler = CreateInstance( + fileSystem: fileSystem, + solution: solution, + releasesProvider: mockReleasesProvider.Object, + trackProjectRetargeting: retargetingService.Object); + + var result = await handler.CheckForRetargetAsync(RetargetCheckOptions.ProjectLoad); + + // result should be null since releases provider returns null + Assert.Null(result); + + // Verify the method was called with includePreview: true + mockReleasesProvider.Verify( + p => p.GetSupportedOrLatestSdkVersionAsync("8.0.100", true, default), + Times.Once); + } + + private static ProjectRetargetHandler CreateInstance( + IDotNetReleasesProvider? releasesProvider = null, + IFileSystem? fileSystem = null, + IProjectThreadingService? threadingService = null, + IVsTrackProjectRetargeting2? trackProjectRetargeting = null, + IVsSolution? solution = null, + IDotNetEnvironment? dotnetEnvironment = null) + { + releasesProvider ??= Mock.Of(); + fileSystem ??= new IFileSystemMock(); + threadingService ??= IProjectThreadingServiceFactory.Create(); + + var retargetingService = IVsServiceFactory.Create(trackProjectRetargeting); + var solutionService = IVsServiceFactory.Create(solution); + + dotnetEnvironment ??= Mock.Of(); + + return new ProjectRetargetHandler( + new Lazy(() => releasesProvider), + fileSystem, + threadingService, + retargetingService, + solutionService, + dotnetEnvironment); + } + + private static IVsSolution CreateSolutionWithDirectory(string directory) + { + return IVsSolutionFactory.CreateWithSolutionDirectory( + (out string solutionDirectory, out string solutionFile, out string userSettings) => + { + solutionDirectory = directory; + solutionFile = Path.Combine(directory, "Solution.sln"); + userSettings = string.Empty; + return HResult.OK; + }); + } +} diff --git a/tests/Microsoft.VisualStudio.ProjectSystem.Managed.VS.UnitTests/ProjectSystem/VS/Setup/DotNetEnvironmentTests.cs b/tests/Microsoft.VisualStudio.ProjectSystem.Managed.VS.UnitTests/ProjectSystem/VS/Setup/DotNetEnvironmentTests.cs new file mode 100644 index 00000000000..6ece637b5b0 --- /dev/null +++ b/tests/Microsoft.VisualStudio.ProjectSystem.Managed.VS.UnitTests/ProjectSystem/VS/Setup/DotNetEnvironmentTests.cs @@ -0,0 +1,240 @@ +// Licensed to the .NET Foundation under one or more agreements. The .NET Foundation licenses this file to you under the MIT license. See the LICENSE.md file in the project root for more information. + +using System.Runtime.InteropServices; +using Microsoft.VisualStudio.IO; +using Microsoft.VisualStudio.ProjectSystem.VS.Utilities; +using Microsoft.Win32; + +namespace Microsoft.VisualStudio.ProjectSystem.VS.Setup; + +public class DotNetEnvironmentTests +{ + [Fact] + public void IsSdkInstalled_WhenSdkNotInRegistry_ReturnsFalse() + { + var fileSystem = new IFileSystemMock(); + var registry = new IRegistryMock(); + var environment = new IEnvironmentMock(); + + var service = CreateInstance(fileSystem, registry, environment); + + bool result = service.IsSdkInstalled("8.0.100"); + + Assert.False(result); + } + + [Fact] + public void IsSdkInstalled_WhenSdkIsInRegistry_ReturnsTrue() + { + var fileSystem = new IFileSystemMock(); + var registry = new IRegistryMock(); + var environment = new IEnvironmentMock(); + + // Setup SDK in registry + registry.SetValue( + RegistryHive.LocalMachine, + RegistryView.Registry32, + @"SOFTWARE\dotnet\Setup\InstalledVersions\x64\sdk", + "8.0.100", + "8.0.100"); + + registry.SetValue( + RegistryHive.LocalMachine, + RegistryView.Registry32, + @"SOFTWARE\dotnet\Setup\InstalledVersions\x64\sdk", + "8.0.200", + "8.0.200"); + + var service = CreateInstance(fileSystem, registry, environment); + + bool result = service.IsSdkInstalled("8.0.100"); + + Assert.True(result); + } + + [Fact] + public void IsSdkInstalled_WithDifferentVersion_ReturnsFalse() + { + var fileSystem = new IFileSystemMock(); + var registry = new IRegistryMock(); + var environment = new IEnvironmentMock(); + + // Setup different SDK version in registry + registry.SetValue( + RegistryHive.LocalMachine, + RegistryView.Registry32, + @"SOFTWARE\dotnet\Setup\InstalledVersions\x64\sdk", + "8.0.200", + "8.0.200"); + + var service = CreateInstance(fileSystem, registry, environment); + + bool result = service.IsSdkInstalled("8.0.100"); + + Assert.False(result); + } + + [Theory] + [InlineData(Architecture.X64, "x64")] + [InlineData(Architecture.X86, "x86")] + [InlineData(Architecture.Arm64, "arm64")] + [InlineData(Architecture.Arm, "arm")] + public void IsSdkInstalled_UsesCorrectArchitecture(Architecture architecture, string expectedArch) + { + var fileSystem = new IFileSystemMock(); + var registry = new IRegistryMock(); + var environment = new IEnvironmentMock(); + + environment.ProcessArchitecture = architecture; + + // Setup SDK in registry for the correct architecture + registry.SetValue( + RegistryHive.LocalMachine, + RegistryView.Registry32, + $@"SOFTWARE\dotnet\Setup\InstalledVersions\{expectedArch}\sdk", + "8.0.100", + "8.0.100"); + + var service = CreateInstance(fileSystem, registry, environment); + + bool result = service.IsSdkInstalled("8.0.100"); + + Assert.True(result); + } + + [Fact] + public void GetDotNetHostPath_WhenRegistryHasInstallLocation_ReturnsPathFromRegistry() + { + var fileSystem = new IFileSystemMock(); + var registry = new IRegistryMock(); + var environment = new IEnvironmentMock(); + + environment.SetFolderPath(Environment.SpecialFolder.ProgramFiles, @"C:\Program Files"); + + string installLocation = @"C:\CustomPath\dotnet"; + string dotnetExePath = Path.Combine(installLocation, "dotnet.exe"); + + registry.SetValue( + RegistryHive.LocalMachine, + RegistryView.Registry32, + @"SOFTWARE\dotnet\Setup\InstalledVersions\x64", + "InstallLocation", + installLocation); + + fileSystem.AddFile(dotnetExePath); + + var service = CreateInstance(fileSystem, registry, environment); + + string? result = service.GetDotNetHostPath(); + + Assert.Equal(dotnetExePath, result); + } + + [Fact] + public void GetDotNetHostPath_WhenRegistryPathDoesNotExist_FallsBackToProgramFiles() + { + var fileSystem = new IFileSystemMock(); + var registry = new IRegistryMock(); + var environment = new IEnvironmentMock(); + + environment.SetFolderPath(Environment.SpecialFolder.ProgramFiles, @"C:\Program Files"); + + string dotnetPath = @"C:\Program Files\dotnet\dotnet.exe"; + fileSystem.AddFile(dotnetPath); + + var service = CreateInstance(fileSystem, registry, environment); + + string? result = service.GetDotNetHostPath(); + + Assert.Equal(dotnetPath, result); + } + + [Fact] + public void GetDotNetHostPath_WhenDotNetNotFound_ReturnsNull() + { + var fileSystem = new IFileSystemMock(); + var registry = new IRegistryMock(); + var environment = new IEnvironmentMock(); + + environment.SetFolderPath(Environment.SpecialFolder.ProgramFiles, @"C:\Program Files"); + + var service = CreateInstance(fileSystem, registry, environment); + + string? result = service.GetDotNetHostPath(); + + Assert.Null(result); + } + + [Theory] + [InlineData(Architecture.X64, "x64")] + [InlineData(Architecture.X86, "x86")] + [InlineData(Architecture.Arm64, "arm64")] + [InlineData(Architecture.Arm, "arm")] + public void GetDotNetHostPath_UsesCorrectArchitecture(Architecture architecture, string expectedArch) + { + var fileSystem = new IFileSystemMock(); + var registry = new IRegistryMock(); + var environment = new IEnvironmentMock(); + + environment.ProcessArchitecture = architecture; + environment.SetFolderPath(Environment.SpecialFolder.ProgramFiles, @"C:\Program Files"); + string installLocation = @"C:\Program Files\"; + + string dotnetExePath = Path.Combine(installLocation, "dotnet.exe"); + + registry.SetValue( + RegistryHive.LocalMachine, + RegistryView.Registry32, + $@"SOFTWARE\dotnet\Setup\InstalledVersions\{expectedArch}", + "InstallLocation", + installLocation); + + fileSystem.AddFile(dotnetExePath); + + var service = CreateInstance(fileSystem, registry, environment); + + string? result = service.GetDotNetHostPath(); + + Assert.Equal(dotnetExePath, result); + } + + [Fact] + public void GetDotNetHostPath_WhenRegistryReturnsInvalidPath_FallsBackToProgramFiles() + { + var fileSystem = new IFileSystemMock(); + var registry = new IRegistryMock(); + var environment = new IEnvironmentMock(); + + environment.SetFolderPath(Environment.SpecialFolder.ProgramFiles, @"C:\Program Files"); + + // Registry points to non-existent path + registry.SetValue( + RegistryHive.LocalMachine, + RegistryView.Registry32, + @"SOFTWARE\dotnet\Setup\InstalledVersions\x64", + "InstallLocation", + @"C:\NonExistent"); + + // But Program Files has it + string dotnetPath = @"C:\Program Files\dotnet\dotnet.exe"; + fileSystem.AddFile(dotnetPath); + + var service = CreateInstance(fileSystem, registry, environment); + + string? result = service.GetDotNetHostPath(); + + Assert.Equal(dotnetPath, result); + } + + private static DotNetEnvironment CreateInstance( + IFileSystem? fileSystem = null, + IRegistryMock? registry = null, + IEnvironmentMock? environment = null) + { + fileSystem ??= new IFileSystemMock(); + registry ??= new IRegistryMock(); + environment ??= new IEnvironmentMock(); + + return new DotNetEnvironment(fileSystem, registry.Object, environment.Object); + } +} diff --git a/tests/Microsoft.VisualStudio.ProjectSystem.Managed.VS.UnitTests/ProjectSystem/VS/Utilities/RegistryServiceTests.cs b/tests/Microsoft.VisualStudio.ProjectSystem.Managed.VS.UnitTests/ProjectSystem/VS/Utilities/RegistryServiceTests.cs new file mode 100644 index 00000000000..81a94c5efae --- /dev/null +++ b/tests/Microsoft.VisualStudio.ProjectSystem.Managed.VS.UnitTests/ProjectSystem/VS/Utilities/RegistryServiceTests.cs @@ -0,0 +1,90 @@ +// Licensed to the .NET Foundation under one or more agreements. The .NET Foundation licenses this file to you under the MIT license. See the LICENSE.md file in the project root for more information. + +using Microsoft.Win32; + +namespace Microsoft.VisualStudio.ProjectSystem.VS.Utilities; + +public class RegistryServiceTests +{ + [Fact] + public void GetValue_WhenKeyDoesNotExist_ReturnsNull() + { + var service = new RegistryService(); + + string? result = service.GetValue( + RegistryHive.LocalMachine, + RegistryView.Registry32, + @"SOFTWARE\NonExistent\Key\Path", + "NonExistentValue"); + + Assert.Null(result); + } + + [Fact] + public void GetValue_WhenValueDoesNotExist_ReturnsNull() + { + var service = new RegistryService(); + + // Use a key that exists but with a non-existent value + string? result = service.GetValue( + RegistryHive.LocalMachine, + RegistryView.Registry32, + @"SOFTWARE\Microsoft\Windows\CurrentVersion", + "NonExistentValue_" + Guid.NewGuid()); + + Assert.Null(result); + } + + [Fact] + public void GetValue_WhenKeyExists_ReturnsValue() + { + var service = new RegistryService(); + + // Try to read a well-known registry value that should exist on Windows + string? result = service.GetValue( + RegistryHive.LocalMachine, + RegistryView.Registry32, + @"SOFTWARE\Microsoft\Windows\CurrentVersion", + "ProgramFilesDir"); + + // On a Windows machine, this should return a path + if (Environment.OSVersion.Platform == PlatformID.Win32NT) + { + Assert.NotNull(result); + Assert.NotEmpty(result); + } + } + + [Fact] + public void GetValueNames_WhenKeyDoesNotExist_ReturnsEmptyArray() + { + var service = new RegistryService(); + + string[] result = service.GetValueNames( + RegistryHive.LocalMachine, + RegistryView.Registry32, + @"SOFTWARE\NonExistent\Key\Path"); + + Assert.Empty(result); + } + + [Fact] + public void GetValueNames_WhenKeyExists_ReturnsValueNames() + { + var service = new RegistryService(); + + // Try to read value names from a well-known registry key + string[] result = service.GetValueNames( + RegistryHive.LocalMachine, + RegistryView.Registry32, + @"SOFTWARE\Microsoft\Windows\CurrentVersion"); + + // On a Windows machine, this key should have at least some values + if (Environment.OSVersion.Platform == PlatformID.Win32NT) + { + Assert.NotEmpty(result); + // Should contain common values like "ProgramFilesDir" + Assert.Contains("ProgramFilesDir", result); + } + } +}