diff --git a/tests/FSharp.Test.Utilities/FSharp.Test.Utilities.fsproj b/tests/FSharp.Test.Utilities/FSharp.Test.Utilities.fsproj index c1aebde1ed3..7bc4f6e058f 100644 --- a/tests/FSharp.Test.Utilities/FSharp.Test.Utilities.fsproj +++ b/tests/FSharp.Test.Utilities/FSharp.Test.Utilities.fsproj @@ -32,6 +32,7 @@ + @@ -93,6 +94,7 @@ + diff --git a/tests/FSharp.Test.Utilities/VSInstallDiscovery.fs b/tests/FSharp.Test.Utilities/VSInstallDiscovery.fs new file mode 100644 index 00000000000..e491ea329bd --- /dev/null +++ b/tests/FSharp.Test.Utilities/VSInstallDiscovery.fs @@ -0,0 +1,128 @@ +// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information. + +namespace FSharp.Test + +/// Test-only Visual Studio installation discovery infrastructure. +/// Provides a centralized, robust, and graceful discovery mechanism for Visual Studio installations +/// used by integration/editor/unit tests under vsintegration/tests. +module VSInstallDiscovery = + + open System + open System.IO + open System.Diagnostics + + /// Result of VS installation discovery + type VSInstallResult = + | Found of installPath: string * source: string + | NotFound of reason: string + + /// Attempts to find a Visual Studio installation using multiple fallback strategies + let tryFindVSInstallation () : VSInstallResult = + + /// Check if a path exists and looks like a valid VS installation + let validateVSPath path = + if String.IsNullOrEmpty(path) then false + else + try + let fullPath = Path.GetFullPath(path) + Directory.Exists(fullPath) && + Directory.Exists(Path.Combine(fullPath, "IDE")) && + (File.Exists(Path.Combine(fullPath, "IDE", "devenv.exe")) || + File.Exists(Path.Combine(fullPath, "IDE", "VSIXInstaller.exe"))) + with + | _ -> false + + /// Strategy 1: VSAPPIDDIR (derive parent of Common7/IDE) + let tryVSAppIdDir () = + let envVar = Environment.GetEnvironmentVariable("VSAPPIDDIR") + if not (String.IsNullOrEmpty(envVar)) then + try + let parentPath = Path.Combine(envVar, "..") + if validateVSPath parentPath then + Some (Found (Path.GetFullPath(parentPath), "VSAPPIDDIR environment variable")) + else None + with + | _ -> None + else None + + /// Strategy 2: Highest version among VS*COMNTOOLS environment variables + let tryVSCommonTools () = + let vsVersions = [ + ("VS180COMNTOOLS", 18) // Visual Studio 2026 + ("VS170COMNTOOLS", 17) // Visual Studio 2022 + ("VS160COMNTOOLS", 16) // Visual Studio 2019 + ("VS150COMNTOOLS", 15) // Visual Studio 2017 + ("VS140COMNTOOLS", 14) // Visual Studio 2015 + ("VS120COMNTOOLS", 12) // Visual Studio 2013 + ] + + vsVersions + |> List.tryPick (fun (envName, version) -> + let envVar = Environment.GetEnvironmentVariable(envName) + if not (String.IsNullOrEmpty(envVar)) then + try + let parentPath = Path.Combine(envVar, "..") + if validateVSPath parentPath then + Some (Found (Path.GetFullPath(parentPath), $"{envName} environment variable (VS version {version})")) + else None + with + | _ -> None + else None) + + /// Strategy 3: vswhere.exe (Visual Studio Installer) + let tryVSWhere () = + try + let programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86) + let vswherePath = Path.Combine(programFiles, "Microsoft Visual Studio", "Installer", "vswhere.exe") + + if File.Exists(vswherePath) then + let startInfo = ProcessStartInfo( + FileName = vswherePath, + Arguments = "-latest -products * -requires Microsoft.Component.MSBuild -property installationPath", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + ) + + use proc = Process.Start(startInfo) + proc.WaitForExit(5000) |> ignore // 5 second timeout + + if proc.ExitCode = 0 then + let output = proc.StandardOutput.ReadToEnd().Trim() + if validateVSPath output then + Some (Found (Path.GetFullPath(output), "vswhere.exe discovery")) + else None + else None + else None + with + | _ -> None + + // Try each strategy in order of precedence + match tryVSAppIdDir () with + | Some result -> result + | None -> + match tryVSCommonTools () with + | Some result -> result + | None -> + match tryVSWhere () with + | Some result -> result + | None -> NotFound "No Visual Studio installation found using any discovery method" + + /// Gets the VS installation directory, with graceful fallback behavior. + /// Returns None if no VS installation can be found, allowing callers to handle gracefully. + let tryGetVSInstallDir () : string option = + match tryFindVSInstallation () with + | Found (path, _) -> Some path + | NotFound _ -> None + + /// Gets the VS installation directory with detailed logging. + /// Useful for debugging installation discovery issues in tests. + let getVSInstallDirWithLogging (logAction: string -> unit) : string option = + match tryFindVSInstallation () with + | Found (path, source) -> + logAction $"Visual Studio installation found at: {path} (via {source})" + Some path + | NotFound reason -> + logAction $"Visual Studio installation not found: {reason}" + None diff --git a/vsintegration/tests/FSharp.Editor.Tests/Helpers/AssemblyResolver.fs b/vsintegration/tests/FSharp.Editor.Tests/Helpers/AssemblyResolver.fs index f41f10fa138..78a57793517 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/Helpers/AssemblyResolver.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/Helpers/AssemblyResolver.fs @@ -8,21 +8,26 @@ open System.Reflection module AssemblyResolver = open System.Globalization + open FSharp.Test.VSInstallDiscovery let vsInstallDir = - // use the environment variable to find the VS installdir - let vsvar = - let var = Environment.GetEnvironmentVariable("VS170COMNTOOLS") - - if String.IsNullOrEmpty var then - Environment.GetEnvironmentVariable("VSAPPIDDIR") - else - var - - if String.IsNullOrEmpty vsvar then - failwith "VS170COMNTOOLS and VSAPPIDDIR environment variables not found." - - Path.Combine(vsvar, "..") + // Use centralized VS installation discovery with graceful fallback + match tryGetVSInstallDir () with + | Some dir -> dir + | None -> + // Fallback to legacy behavior for backward compatibility + let vsvar = + let var = Environment.GetEnvironmentVariable("VS170COMNTOOLS") + + if String.IsNullOrEmpty var then + Environment.GetEnvironmentVariable("VSAPPIDDIR") + else + var + + if String.IsNullOrEmpty vsvar then + failwith "VS170COMNTOOLS and VSAPPIDDIR environment variables not found." + + Path.Combine(vsvar, "..") let probingPaths = [| diff --git a/vsintegration/tests/Salsa/VisualFSharp.Salsa.fsproj b/vsintegration/tests/Salsa/VisualFSharp.Salsa.fsproj index 6396eada6a9..f7410214430 100644 --- a/vsintegration/tests/Salsa/VisualFSharp.Salsa.fsproj +++ b/vsintegration/tests/Salsa/VisualFSharp.Salsa.fsproj @@ -16,7 +16,7 @@ - + CompilerLocation.fs diff --git a/vsintegration/tests/UnitTests/AssemblyResolver.fs b/vsintegration/tests/UnitTests/AssemblyResolver.fs index aab95cc46fc..38b5ee45290 100644 --- a/vsintegration/tests/UnitTests/AssemblyResolver.fs +++ b/vsintegration/tests/UnitTests/AssemblyResolver.fs @@ -6,17 +6,22 @@ open System.Reflection module AssemblyResolver = open System.Globalization + open FSharp.Test.VSInstallDiscovery let vsInstallDir = - // use the environment variable to find the VS installdir - let vsvar = - let var = Environment.GetEnvironmentVariable("VS170COMNTOOLS") - if String.IsNullOrEmpty var then - Environment.GetEnvironmentVariable("VSAPPIDDIR") - else - var - if String.IsNullOrEmpty vsvar then failwith "VS170COMNTOOLS and VSAPPIDDIR environment variables not found." - Path.Combine(vsvar, "..") + // Use centralized VS installation discovery with graceful fallback + match tryGetVSInstallDir () with + | Some dir -> dir + | None -> + // Fallback to legacy behavior for backward compatibility + let vsvar = + let var = Environment.GetEnvironmentVariable("VS170COMNTOOLS") + if String.IsNullOrEmpty var then + Environment.GetEnvironmentVariable("VSAPPIDDIR") + else + var + if String.IsNullOrEmpty vsvar then failwith "VS170COMNTOOLS and VSAPPIDDIR environment variables not found." + Path.Combine(vsvar, "..") let probingPaths = [| Path.Combine(vsInstallDir, @"IDE\CommonExtensions\Microsoft\Editor")