diff --git a/azure-pipelines-PR.yml b/azure-pipelines-PR.yml
index cfa5411ee33..eee3e74008b 100644
--- a/azure-pipelines-PR.yml
+++ b/azure-pipelines-PR.yml
@@ -55,6 +55,8 @@ variables:
value: Release
- name: _PublishUsingPipelines
value: true
+ - name: _WindowsMachineQueueName
+ value: windows.vs2026preview.scout.amd64.open
- name: VisualStudioDropName
value: Products/$(System.TeamProject)/$(Build.Repository.Name)/$(Build.SourceBranchName)/$(Build.BuildNumber)
- name: Codeql.Enabled
@@ -101,7 +103,7 @@ stages:
value: Test
pool:
name: $(DncEngPublicBuildPool)
- demands: ImageOverride -equals $(WindowsMachineQueueName)
+ demands: ImageOverride -equals $(_WindowsMachineQueueName)
timeoutInMinutes: 90
strategy:
maxParallel: 2
@@ -216,12 +218,8 @@ stages:
- job: WindowsLangVersionPreview
pool:
- # The PR build definition sets this variable:
- # WindowsMachineQueueName=Windows.vs2022.amd64.open
- # and there is an alternate build definition that sets this to a queue that is always scouting the
- # next preview of Visual Studio.
name: $(DncEngPublicBuildPool)
- demands: ImageOverride -equals $(WindowsMachineQueueName)
+ demands: ImageOverride -equals $(_WindowsMachineQueueName)
timeoutInMinutes: 120
steps:
- checkout: self
@@ -256,12 +254,8 @@ stages:
- job: WindowsNoRealsig_testCoreclr
pool:
- # The PR build definition sets this variable:
- # WindowsMachineQueueName=Windows.vs2022.amd64.open
- # and there is an alternate build definition that sets this to a queue that is always scouting the
- # next preview of Visual Studio.
name: $(DncEngPublicBuildPool)
- demands: ImageOverride -equals $(WindowsMachineQueueName)
+ demands: ImageOverride -equals $(_WindowsMachineQueueName)
timeoutInMinutes: 120
steps:
- checkout: self
@@ -306,12 +300,8 @@ stages:
- job: WindowsNoRealsig_testDesktop
pool:
- # The PR build definition sets this variable:
- # WindowsMachineQueueName=Windows.vs2022.amd64.open
- # and there is an alternate build definition that sets this to a queue that is always scouting the
- # next preview of Visual Studio.
name: $(DncEngPublicBuildPool)
- demands: ImageOverride -equals $(WindowsMachineQueueName)
+ demands: ImageOverride -equals $(_WindowsMachineQueueName)
timeoutInMinutes: 120
strategy:
parallel: 4
@@ -359,12 +349,8 @@ stages:
- job: WindowsStrictIndentation
pool:
- # The PR build definition sets this variable:
- # WindowsMachineQueueName=Windows.vs2022.amd64.open
- # and there is an alternate build definition that sets this to a queue that is always scouting the
- # next preview of Visual Studio.
name: $(DncEngPublicBuildPool)
- demands: ImageOverride -equals $(WindowsMachineQueueName)
+ demands: ImageOverride -equals $(_WindowsMachineQueueName)
timeoutInMinutes: 120
steps:
- checkout: self
@@ -400,7 +386,7 @@ stages:
- job: WindowsNoStrictIndentation
pool:
name: $(DncEngPublicBuildPool)
- demands: ImageOverride -equals $(WindowsMachineQueueName)
+ demands: ImageOverride -equals $(_WindowsMachineQueueName)
timeoutInMinutes: 120
steps:
- checkout: self
@@ -441,12 +427,8 @@ stages:
- name: __VSNeverShowWhatsNew
value: 1
pool:
- # The PR build definition sets this variable:
- # WindowsMachineQueueName=Windows.vs2022.amd64.open
- # and there is an alternate build definition that sets this to a queue that is always scouting the
- # next preview of Visual Studio.
name: $(DncEngPublicBuildPool)
- demands: ImageOverride -equals $(WindowsMachineQueueName)
+ demands: ImageOverride -equals $(_WindowsMachineQueueName)
timeoutInMinutes: 120
strategy:
matrix:
@@ -546,12 +528,8 @@ stages:
- name: __VSNeverShowWhatsNew
value: 1
pool:
- # The PR build definition sets this variable:
- # WindowsMachineQueueName=Windows.vs2022.amd64.open
- # and there is an alternate build definition that sets this to a queue that is always scouting the
- # next preview of Visual Studio.
name: $(DncEngPublicBuildPool)
- demands: ImageOverride -equals $(WindowsMachineQueueName)
+ demands: ImageOverride -equals $(_WindowsMachineQueueName)
timeoutInMinutes: 120
strategy:
parallel: 4
@@ -622,7 +600,7 @@ stages:
- job: MockOfficial
pool:
name: $(DncEngPublicBuildPool)
- demands: ImageOverride -equals $(WindowsMachineQueueName)
+ demands: ImageOverride -equals $(_WindowsMachineQueueName)
steps:
- checkout: self
clean: true
@@ -721,7 +699,7 @@ stages:
- job: EndToEndBuildTests
pool:
name: $(DncEngPublicBuildPool)
- demands: ImageOverride -equals $(WindowsMachineQueueName)
+ demands: ImageOverride -equals $(_WindowsMachineQueueName)
strategy:
maxParallel: 2
matrix:
@@ -761,7 +739,7 @@ stages:
- job: Plain_Build_Windows
pool:
name: $(DncEngPublicBuildPool)
- demands: ImageOverride -equals $(WindowsMachineQueueName)
+ demands: ImageOverride -equals $(_WindowsMachineQueueName)
variables:
- name: _BuildConfig
value: Debug
@@ -816,7 +794,7 @@ stages:
- job: Benchmarks
pool:
name: $(DncEngPublicBuildPool)
- demands: ImageOverride -equals $(WindowsMachineQueueName)
+ demands: ImageOverride -equals $(_WindowsMachineQueueName)
variables:
- name: _BuildConfig
value: Release
@@ -832,7 +810,7 @@ stages:
- job: Build_And_Test_AOT_Windows
pool:
name: $(DncEngPublicBuildPool)
- demands: ImageOverride -equals $(WindowsMachineQueueName)
+ demands: ImageOverride -equals $(_WindowsMachineQueueName)
strategy:
maxParallel: 2
matrix:
@@ -873,7 +851,7 @@ stages:
- job: ILVerify
pool:
name: $(DncEngPublicBuildPool)
- demands: ImageOverride -equals $(WindowsMachineQueueName)
+ demands: ImageOverride -equals $(_WindowsMachineQueueName)
steps:
- checkout: self
clean: true
diff --git a/tests/FSharp.Test.Utilities/FSharp.Test.Utilities.fsproj b/tests/FSharp.Test.Utilities/FSharp.Test.Utilities.fsproj
index c1aebde1ed3..d899b551e30 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 @@
+
@@ -100,7 +102,8 @@
- $(NoWarn);NU1510
+ $(NoWarn);NU1510;44
+
diff --git a/tests/FSharp.Test.Utilities/VSInstallDiscovery.fs b/tests/FSharp.Test.Utilities/VSInstallDiscovery.fs
new file mode 100644
index 00000000000..e59eec8eed2
--- /dev/null
+++ b/tests/FSharp.Test.Utilities/VSInstallDiscovery.fs
@@ -0,0 +1,180 @@
+// 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
+
+ /// Gets the VS installation directory or fails with a detailed error message.
+ /// This is the recommended method for test scenarios that require VS to be installed.
+ let getVSInstallDirOrFail () : string =
+ match tryFindVSInstallation () with
+ | Found (path, _) -> path
+ | NotFound reason ->
+ failwith $"Visual Studio installation not found: {reason}. Ensure VS is installed or environment variables (VSAPPIDDIR, VS*COMNTOOLS) are set."
+
+/// Assembly resolver for Visual Studio test infrastructure.
+/// Provides centralized assembly resolution for VS integration tests.
+module VSAssemblyResolver =
+ open System
+ open System.IO
+ open System.Reflection
+ open System.Globalization
+
+ /// Adds an assembly resolver that probes Visual Studio installation directories.
+ /// This should be called early in test initialization to ensure VS assemblies can be loaded.
+ let addResolver () =
+ let vsInstallDir = VSInstallDiscovery.getVSInstallDirOrFail ()
+
+ let probingPaths =
+ [|
+ Path.Combine(vsInstallDir, @"IDE\CommonExtensions\Microsoft\Editor")
+ Path.Combine(vsInstallDir, @"IDE\PublicAssemblies")
+ Path.Combine(vsInstallDir, @"IDE\PrivateAssemblies")
+ Path.Combine(vsInstallDir, @"IDE\CommonExtensions\Microsoft\ManagedLanguages\VBCSharp\LanguageServices")
+ Path.Combine(vsInstallDir, @"IDE\Extensions\Microsoft\CodeSense\Framework")
+ Path.Combine(vsInstallDir, @"IDE")
+ |]
+
+ AppDomain.CurrentDomain.add_AssemblyResolve(fun _ args ->
+ let found () =
+ probingPaths
+ |> Seq.tryPick (fun p ->
+ try
+ let name = AssemblyName(args.Name)
+ let codebase = Path.GetFullPath(Path.Combine(p, name.Name) + ".dll")
+ if File.Exists(codebase) then
+ name.CodeBase <- codebase
+ name.CultureInfo <- Unchecked.defaultof
+ name.Version <- Unchecked.defaultof
+ Some name
+ else
+ None
+ with _ ->
+ None)
+
+ match found () with
+ | None -> Unchecked.defaultof
+ | Some name -> Assembly.Load(name))
diff --git a/vsintegration/tests/FSharp.Editor.Tests/Helpers/AssemblyResolver.fs b/vsintegration/tests/FSharp.Editor.Tests/Helpers/AssemblyResolver.fs
index f41f10fa138..ef8c01ca908 100644
--- a/vsintegration/tests/FSharp.Editor.Tests/Helpers/AssemblyResolver.fs
+++ b/vsintegration/tests/FSharp.Editor.Tests/Helpers/AssemblyResolver.fs
@@ -2,57 +2,9 @@
namespace FSharp.Editor.Tests.Helpers
-open System
-open System.IO
-open System.Reflection
-
module AssemblyResolver =
- open System.Globalization
-
- 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, "..")
-
- let probingPaths =
- [|
- Path.Combine(vsInstallDir, @"IDE\CommonExtensions\Microsoft\Editor")
- Path.Combine(vsInstallDir, @"IDE\PublicAssemblies")
- Path.Combine(vsInstallDir, @"IDE\PrivateAssemblies")
- Path.Combine(vsInstallDir, @"IDE\CommonExtensions\Microsoft\ManagedLanguages\VBCSharp\LanguageServices")
- Path.Combine(vsInstallDir, @"IDE\Extensions\Microsoft\CodeSense\Framework")
- Path.Combine(vsInstallDir, @"IDE")
- |]
-
- let addResolver () =
- AppDomain.CurrentDomain.add_AssemblyResolve (fun h args ->
- let found () =
- (probingPaths)
- |> Seq.tryPick (fun p ->
- try
- let name = AssemblyName(args.Name)
- let codebase = Path.GetFullPath(Path.Combine(p, name.Name) + ".dll")
-
- if File.Exists(codebase) then
- name.CodeBase <- codebase
- name.CultureInfo <- Unchecked.defaultof
- name.Version <- Unchecked.defaultof
- Some(name)
- else
- None
- with _ ->
- None)
+ open FSharp.Test.VSAssemblyResolver
- match found () with
- | None -> Unchecked.defaultof
- | Some name -> Assembly.Load(name))
+ /// Adds an assembly resolver that probes Visual Studio installation directories.
+ /// This is a compatibility shim that delegates to the centralized implementation.
+ let addResolver = addResolver
diff --git a/vsintegration/tests/Salsa/VisualFSharp.Salsa.fsproj b/vsintegration/tests/Salsa/VisualFSharp.Salsa.fsproj
index 6396eada6a9..d41b116c21e 100644
--- a/vsintegration/tests/Salsa/VisualFSharp.Salsa.fsproj
+++ b/vsintegration/tests/Salsa/VisualFSharp.Salsa.fsproj
@@ -16,7 +16,7 @@
-
+
CompilerLocation.fs
@@ -24,6 +24,9 @@
UnitTests.TestLib.Utils.fs
+
+ VSInstallDiscovery.fs
+
diff --git a/vsintegration/tests/Salsa/VsMocks.fs b/vsintegration/tests/Salsa/VsMocks.fs
index 5189b7faec5..54fe8b906de 100644
--- a/vsintegration/tests/Salsa/VsMocks.fs
+++ b/vsintegration/tests/Salsa/VsMocks.fs
@@ -1642,6 +1642,7 @@ module internal VsActual =
open System.ComponentModel.Composition.Primitives
open Microsoft.VisualStudio.Text
open Microsoft.VisualStudio.Threading
+ open FSharp.Test.VSInstallDiscovery
type TestExportJoinableTaskContext () =
@@ -1650,16 +1651,7 @@ module internal VsActual =
[)>]
member public _.JoinableTaskContext : JoinableTaskContext = jtc
- 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, "..")
+ let vsInstallDir = getVSInstallDirOrFail ()
let CreateEditorCatalog() =
let thisAssembly = Assembly.GetExecutingAssembly().Location
diff --git a/vsintegration/tests/UnitTests/AssemblyResolver.fs b/vsintegration/tests/UnitTests/AssemblyResolver.fs
index aab95cc46fc..cf36b723e40 100644
--- a/vsintegration/tests/UnitTests/AssemblyResolver.fs
+++ b/vsintegration/tests/UnitTests/AssemblyResolver.fs
@@ -1,47 +1,8 @@
namespace Microsoft.VisualStudio.FSharp
-open System
-open System.IO
-open System.Reflection
-
module AssemblyResolver =
- open System.Globalization
-
- 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, "..")
-
- let probingPaths = [|
- Path.Combine(vsInstallDir, @"IDE\CommonExtensions\Microsoft\Editor")
- Path.Combine(vsInstallDir, @"IDE\PublicAssemblies")
- Path.Combine(vsInstallDir, @"IDE\PrivateAssemblies")
- Path.Combine(vsInstallDir, @"IDE\CommonExtensions\Microsoft\ManagedLanguages\VBCSharp\LanguageServices")
- Path.Combine(vsInstallDir, @"IDE\Extensions\Microsoft\CodeSense\Framework")
- Path.Combine(vsInstallDir, @"IDE")
- |]
+ open FSharp.Test.VSAssemblyResolver
- let addResolver () =
- AppDomain.CurrentDomain.add_AssemblyResolve(fun h args ->
- let found () =
- (probingPaths ) |> Seq.tryPick(fun p ->
- try
- let name = AssemblyName(args.Name)
- let codebase = Path.GetFullPath(Path.Combine(p, name.Name) + ".dll")
- if File.Exists(codebase) then
- name.CodeBase <- codebase
- name.CultureInfo <- Unchecked.defaultof
- name.Version <- Unchecked.defaultof
- Some (name)
- else None
- with | _ -> None
- )
- match found() with
- | None -> Unchecked.defaultof
- | Some name -> Assembly.Load(name) )
+ /// Adds an assembly resolver that probes Visual Studio installation directories.
+ /// This is a compatibility shim that delegates to the centralized implementation.
+ let addResolver = addResolver