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")