From c08687a5abec6c7d716ad1501998068c06fe3bdb Mon Sep 17 00:00:00 2001
From: Adam Boniecki <20281641+abonie@users.noreply.github.com>
Date: Wed, 10 Jun 2026 15:53:53 +0200
Subject: [PATCH 01/13] Enable VS integration tests on CI
---
azure-pipelines-PR.yml | 8 ++++----
eng/Build.ps1 | 2 +-
2 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/azure-pipelines-PR.yml b/azure-pipelines-PR.yml
index b627fab985c..9e5b5cfb4f7 100644
--- a/azure-pipelines-PR.yml
+++ b/azure-pipelines-PR.yml
@@ -448,10 +448,10 @@ stages:
_testKind: testCoreclr
TEST_TRANSPARENT_COMPILER: 1 # Pipeline variable will map to env var.
transparentCompiler: TransparentCompiler
- # inttests_release:
- # _configuration: Release
- # _testKind: testIntegration
- # setupVsHive: true
+ inttests_release:
+ _configuration: Release
+ _testKind: testIntegration
+ setupVsHive: true
steps:
- checkout: self
clean: true
diff --git a/eng/Build.ps1 b/eng/Build.ps1
index 41a52df6395..508dda563a1 100644
--- a/eng/Build.ps1
+++ b/eng/Build.ps1
@@ -665,7 +665,7 @@ try {
TestUsingMSBuild -testProject "$RepoRoot\vsintegration\tests\UnitTests\VisualFSharp.UnitTests.fsproj" -targetFramework $script:desktopTargetFramework
}
- if ($testIntegration) {
+ if ($testIntegration -and -not $noVisualStudio) {
TestUsingMSBuild -testProject "$RepoRoot\vsintegration\tests\FSharp.Editor.IntegrationTests\FSharp.Editor.IntegrationTests.csproj" -targetFramework $script:desktopTargetFramework
}
From 33c46fd10dd3ac55207c800ab3f7cfbf071bbf6a Mon Sep 17 00:00:00 2001
From: Adam Boniecki <20281641+abonie@users.noreply.github.com>
Date: Wed, 10 Jun 2026 17:21:23 +0200
Subject: [PATCH 02/13] Use xunit.console.exe to run integration tests
Can't use `dotnet` due to a conflict with mtp/xunit3
---
eng/Build.ps1 | 40 ++++++++++++++++++-
eng/Versions.props | 2 +
.../FSharp.Editor.IntegrationTests.csproj | 9 +++++
3 files changed, 50 insertions(+), 1 deletion(-)
diff --git a/eng/Build.ps1 b/eng/Build.ps1
index 508dda563a1..8bfcce6c0ab 100644
--- a/eng/Build.ps1
+++ b/eng/Build.ps1
@@ -402,6 +402,44 @@ function TestUsingMSBuild([string] $testProject, [string] $targetFramework, [str
Exec-Console $dotnetExe $test_args
}
+# Runs a test assembly via the xUnit v2 console runner, bypassing `dotnet test` (and therefore
+# the repo-wide Microsoft.Testing.Platform gate declared in global.json). Used only for projects
+# whose harness cannot run under MTP -- today that is FSharp.Editor.IntegrationTests, which
+# depends on Microsoft.VisualStudio.Extensibility.Testing.Xunit (VS-hive launcher, xUnit-v2-locked).
+# The runner is restored via a on the
+# test project, so it is present in the NuGet global packages folder after a normal slnx restore.
+function TestUsingXUnitConsole([string] $testProject, [string] $targetFramework) {
+
+ $projectName = [System.IO.Path]::GetFileNameWithoutExtension($testProject)
+ $assemblyPath = "$ArtifactsDir\bin\$projectName\$configuration\$targetFramework\$projectName.dll"
+ if (-not (Test-Path $assemblyPath)) {
+ throw "Test assembly not found at $assemblyPath. Was $projectName built before -testIntegration was invoked?"
+ }
+
+ $runnerVersion = (Select-Xml -Path "$RepoRoot\eng\Versions.props" -XPath "//XunitRunnerConsoleV2Version").Node.InnerText
+ if ([string]::IsNullOrWhiteSpace($runnerVersion)) {
+ throw "XunitRunnerConsoleV2Version is not defined in eng\Versions.props."
+ }
+
+ $nugetRoot = GetNuGetPackageCachePath
+ $xunitConsole = Join-Path $nugetRoot "xunit.runner.console\$runnerVersion\tools\net472\xunit.console.exe"
+ if (-not (Test-Path $xunitConsole)) {
+ throw "xunit.console.exe not found at $xunitConsole. Ensure restore of $projectName ran first (PackageDownload of xunit.runner.console v$runnerVersion)."
+ }
+
+ $testResultsDir = "$ArtifactsDir\TestResults\$configuration"
+ Create-Directory $testResultsDir
+ $jobName = if ($env:SYSTEM_JOBNAME) { $env:SYSTEM_JOBNAME } else { "local" }
+ $resultsXml = Join-Path $testResultsDir "$projectName.$targetFramework.$jobName.xml"
+
+ # -parallel none / -noshadow mirror the project's xunit.runner.json (parallelizeTestCollections=false, shadowCopy=false).
+ $xunit_args = """$assemblyPath"" -xml ""$resultsXml"" -parallel none -noshadow -nologo"
+
+ Write-Host("$xunitConsole $xunit_args")
+
+ Exec-Console $xunitConsole $xunit_args
+}
+
function Prepare-TempDir() {
Copy-Item (Join-Path $RepoRoot "tests\Resources\Directory.Build.props") $TempDir
Copy-Item (Join-Path $RepoRoot "tests\Resources\Directory.Build.targets") $TempDir
@@ -666,7 +704,7 @@ try {
}
if ($testIntegration -and -not $noVisualStudio) {
- TestUsingMSBuild -testProject "$RepoRoot\vsintegration\tests\FSharp.Editor.IntegrationTests\FSharp.Editor.IntegrationTests.csproj" -targetFramework $script:desktopTargetFramework
+ TestUsingXUnitConsole -testProject "$RepoRoot\vsintegration\tests\FSharp.Editor.IntegrationTests\FSharp.Editor.IntegrationTests.csproj" -targetFramework $script:desktopTargetFramework
}
if ($testAOT) {
diff --git a/eng/Versions.props b/eng/Versions.props
index 08cde86aa77..7c3283063e6 100644
--- a/eng/Versions.props
+++ b/eng/Versions.props
@@ -161,6 +161,8 @@
13.0.4
3.2.2
3.2.2
+
+ 2.9.3
8.0.0
diff --git a/vsintegration/tests/FSharp.Editor.IntegrationTests/FSharp.Editor.IntegrationTests.csproj b/vsintegration/tests/FSharp.Editor.IntegrationTests/FSharp.Editor.IntegrationTests.csproj
index 374c8164a5b..af0a08656ae 100644
--- a/vsintegration/tests/FSharp.Editor.IntegrationTests/FSharp.Editor.IntegrationTests.csproj
+++ b/vsintegration/tests/FSharp.Editor.IntegrationTests/FSharp.Editor.IntegrationTests.csproj
@@ -35,6 +35,15 @@
+
+
+
+
+
From 1c638d26eb35bc0850b96b35f5081516ed9c1eac Mon Sep 17 00:00:00 2001
From: Adam Boniecki <20281641+abonie@users.noreply.github.com>
Date: Wed, 10 Jun 2026 18:14:22 +0200
Subject: [PATCH 03/13] Refactor to use helper Get-PackageVersion
---
eng/Build.ps1 | 11 ++++-------
1 file changed, 4 insertions(+), 7 deletions(-)
diff --git a/eng/Build.ps1 b/eng/Build.ps1
index 8bfcce6c0ab..960642bac95 100644
--- a/eng/Build.ps1
+++ b/eng/Build.ps1
@@ -416,13 +416,10 @@ function TestUsingXUnitConsole([string] $testProject, [string] $targetFramework)
throw "Test assembly not found at $assemblyPath. Was $projectName built before -testIntegration was invoked?"
}
- $runnerVersion = (Select-Xml -Path "$RepoRoot\eng\Versions.props" -XPath "//XunitRunnerConsoleV2Version").Node.InnerText
- if ([string]::IsNullOrWhiteSpace($runnerVersion)) {
- throw "XunitRunnerConsoleV2Version is not defined in eng\Versions.props."
- }
-
- $nugetRoot = GetNuGetPackageCachePath
- $xunitConsole = Join-Path $nugetRoot "xunit.runner.console\$runnerVersion\tools\net472\xunit.console.exe"
+ # Get-PackageVersion (eng\build-utils.ps1) reads from eng\Versions.props,
+ # and Get-PackageDir resolves the NuGet cache path. Defensive Trim() covers accidental whitespace in the value.
+ $runnerVersion = (Get-PackageVersion "XunitRunnerConsoleV2").Trim()
+ $xunitConsole = Join-Path (Get-PackageDir "xunit.runner.console" $runnerVersion) "tools\net472\xunit.console.exe"
if (-not (Test-Path $xunitConsole)) {
throw "xunit.console.exe not found at $xunitConsole. Ensure restore of $projectName ran first (PackageDownload of xunit.runner.console v$runnerVersion)."
}
From 534e1c843fb684d2a95237443d03c4c25e467a73 Mon Sep 17 00:00:00 2001
From: Adam Boniecki <20281641+abonie@users.noreply.github.com>
Date: Thu, 11 Jun 2026 10:02:04 +0200
Subject: [PATCH 04/13] Copy test dll config to build output
---
.../FSharp.Editor.IntegrationTests.csproj | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/vsintegration/tests/FSharp.Editor.IntegrationTests/FSharp.Editor.IntegrationTests.csproj b/vsintegration/tests/FSharp.Editor.IntegrationTests/FSharp.Editor.IntegrationTests.csproj
index af0a08656ae..9b04ed904fd 100644
--- a/vsintegration/tests/FSharp.Editor.IntegrationTests/FSharp.Editor.IntegrationTests.csproj
+++ b/vsintegration/tests/FSharp.Editor.IntegrationTests/FSharp.Editor.IntegrationTests.csproj
@@ -46,6 +46,10 @@
+
+
From ee4a258fdc4596d90a960225a488ff12c77bd1f1 Mon Sep 17 00:00:00 2001
From: Adam Boniecki <20281641+abonie@users.noreply.github.com>
Date: Thu, 11 Jun 2026 11:10:06 +0200
Subject: [PATCH 05/13] Bump vs-extensibility-testing
---
eng/Versions.props | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/eng/Versions.props b/eng/Versions.props
index 7c3283063e6..f93e881a823 100644
--- a/eng/Versions.props
+++ b/eng/Versions.props
@@ -130,7 +130,7 @@
$(VisualStudioEditorPackagesVersion)
$(VisualStudioEditorPackagesVersion)
17.14.0
- 0.1.800-beta
+ 5.3.0-2.26055.8
$(MicrosoftVisualStudioExtensibilityTestingVersion)
From 37d91f4555a7f739bdc121f9edfc19a53ff7c3fb Mon Sep 17 00:00:00 2001
From: Adam Boniecki <20281641+abonie@users.noreply.github.com>
Date: Thu, 11 Jun 2026 13:11:24 +0200
Subject: [PATCH 06/13] Use dotnet new for test projects
---
.../InProcess/SolutionExplorerInProcess.cs | 137 ++++++++++++++++--
.../WellKnownProjectTemplates.cs | 9 +-
2 files changed, 131 insertions(+), 15 deletions(-)
diff --git a/vsintegration/tests/FSharp.Editor.IntegrationTests/InProcess/SolutionExplorerInProcess.cs b/vsintegration/tests/FSharp.Editor.IntegrationTests/InProcess/SolutionExplorerInProcess.cs
index 43743484e96..430a4940889 100644
--- a/vsintegration/tests/FSharp.Editor.IntegrationTests/InProcess/SolutionExplorerInProcess.cs
+++ b/vsintegration/tests/FSharp.Editor.IntegrationTests/InProcess/SolutionExplorerInProcess.cs
@@ -4,9 +4,11 @@
using System;
using System.Collections.Generic;
+using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Runtime.CompilerServices;
+using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.VisualStudio.OperationProgress;
@@ -50,17 +52,42 @@ public async Task CreateSingleProjectSolutionAsync(string name, string template,
await AddProjectAsync(name, template, cancellationToken);
}
- // Repo root from compile-time source path — no runtime resolution needed.
+ // Repo root from compile-time source path -- no runtime resolution needed.
private static readonly string RepoRoot = Path.GetFullPath(Path.Combine(
Path.GetDirectoryName(GetSourceFilePath())!, "..", "..", "..", ".."));
+ // Configuration the test assembly itself was built with (Release on CI, typically Debug locally).
+ // Layout: /artifacts/bin/FSharp.Editor.IntegrationTests///FSharp.Editor.IntegrationTests.dll
+ // The synthesized standalone project (CreateStandaloneProjectFile) must reference the matching
+ // fsc.dll, otherwise the VS build fails with "Could not find file ...artifacts/bin/fsc///fsc.dll".
+ private static readonly string LocalCompilerConfiguration = new DirectoryInfo(
+ Path.GetDirectoryName(typeof(SolutionExplorerInProcess).Assembly.Location)!).Parent!.Name;
+
+ // Repo-pinned dotnet host installed by Arcade. Falls back to PATH lookup if not present
+ // (developer scenarios that build the integration project outside the repo's eng infra).
+ private static readonly string DotnetExe =
+ File.Exists(Path.Combine(RepoRoot, ".dotnet", "dotnet.exe"))
+ ? Path.Combine(RepoRoot, ".dotnet", "dotnet.exe")
+ : "dotnet";
+
private static string CreateStandaloneProjectFile()
{
var propsPath = Path.Combine(RepoRoot, "UseLocalCompiler.Directory.Build.props");
+ // Sanity-check the inferred configuration: matching fsc must exist before VS tries to build.
+ // Validated here (not in the static initializer) so tests that don't use the standalone path
+ // can still load this type when fsc hasn't been built locally.
+ var fscRoot = Path.Combine(RepoRoot, "artifacts", "bin", "fsc", LocalCompilerConfiguration);
+ if (!Directory.Exists(fscRoot))
+ {
+ throw new InvalidOperationException(
+ $"Inferred LocalCompilerConfiguration='{LocalCompilerConfiguration}' but no built fsc found at '{fscRoot}'. " +
+ $"The synthesized standalone fsproj would fail to load fsc.dll -- build the F# compiler in this configuration first.");
+ }
+
return $@"
- Debug
+ {LocalCompilerConfiguration}
{RepoRoot}
@@ -115,22 +142,108 @@ private async Task GetDirectoryNameAsync(CancellationToken cancellationT
public async Task AddProjectAsync(string projectName, string projectTemplate, CancellationToken cancellationToken)
{
- await JoinableTaskFactory.SwitchToMainThreadAsync(cancellationToken);
+ // dotnet new must run off the UI thread (it's a synchronous shell-out).
+ var solutionDir = await GetDirectoryNameAsync(cancellationToken);
+ var projectDir = Path.Combine(solutionDir, projectName);
- var projectPath = Path.Combine(await GetDirectoryNameAsync(cancellationToken), projectName);
- var projectTemplatePath = await GetProjectTemplatePathAsync(projectTemplate, cancellationToken);
- var solution = await GetRequiredGlobalServiceAsync(cancellationToken);
- ErrorHandler.ThrowOnFailure(solution.AddNewProjectFromTemplate(projectTemplatePath, null, null, projectPath, projectName, null, out _));
- }
+ await TaskScheduler.Default;
+ await RunDotnetNewAsync(projectTemplate, projectName, projectDir, cancellationToken);
- private async Task GetProjectTemplatePathAsync(string projectTemplate, CancellationToken cancellationToken)
- {
- await JoinableTaskFactory.SwitchToMainThreadAsync(cancellationToken);
+ var projectFilePath = Path.Combine(projectDir, $"{projectName}.fsproj");
+ if (!File.Exists(projectFilePath))
+ {
+ throw new InvalidOperationException(
+ $"'dotnet new {projectTemplate}' completed but did not produce '{projectFilePath}'.");
+ }
+ await JoinableTaskFactory.SwitchToMainThreadAsync(cancellationToken);
var dte = await GetRequiredGlobalServiceAsync(cancellationToken);
var solution = (EnvDTE80.Solution2)dte.Solution;
+ _ = solution.AddFromFile(projectFilePath, false);
+
+ // Auto-open the project's main .fs file. The previous AddNewProjectFromTemplate path opened
+ // the file marked OpenInEditor="true" in the .vstemplate; tests rely on this implicit open
+ // (e.g. CodeActionTests calls Editor.SetTextAsync immediately afterwards).
+ // SDK templates currently produce exactly one .fs file per project (Library.fs / Program.fs / Tests.fs).
+ // If that ever changes (e.g. xunit template growing a Program.fs), fail loudly rather than
+ // silently opening the wrong file and producing confusing content-diff failures downstream.
+ var fsFiles = Directory.EnumerateFiles(projectDir, "*.fs", SearchOption.TopDirectoryOnly)
+ .OrderBy(p => p, StringComparer.OrdinalIgnoreCase)
+ .ToList();
+ if (fsFiles.Count != 1)
+ {
+ throw new InvalidOperationException(
+ $"Expected exactly one *.fs file in '{projectDir}' produced by 'dotnet new {projectTemplate}', " +
+ $"found {fsFiles.Count}: [{string.Join(", ", fsFiles.Select(Path.GetFileName))}]. " +
+ $"Update AddProjectAsync's auto-open logic to pick the correct main file for this template.");
+ }
+ await OpenFileAsync(projectName, Path.GetFileName(fsFiles[0]), cancellationToken);
+ }
+
+ // Shells out to `dotnet new -lang F# -o --name ` for project creation.
+ // Replaces solution.GetProjectTemplate(...) + AddNewProjectFromTemplate which required VS-side
+ // .vstemplate registration; the SDK templates are the source of truth in modern .NET F# workflows
+ // and avoid coupling tests to VS template-cache state in the experimental hive.
+ //
+ // Hermeticity:
+ // * WorkingDirectory=RepoRoot ensures global.json is found and the repo-pinned SDK is used
+ // (otherwise dotnet new walks up from VS's cwd and may pick up a different machine SDK,
+ // which can produce subtly different template output and break content-equality assertions).
+ // * DotnetExe prefers the Arcade-installed dotnet at $RepoRoot/.dotnet/dotnet.exe.
+ // * --no-restore: dotnet new for xunit triggers an implicit restore by default; tests that
+ // need restore call SolutionExplorer.RestoreNuGetPackagesAsync via VS's restore service afterwards.
+ private static async Task RunDotnetNewAsync(string template, string name, string outputDir, CancellationToken cancellationToken)
+ {
+ Directory.CreateDirectory(outputDir);
+
+ var psi = new ProcessStartInfo(DotnetExe, $"new {template} -lang F# -o \"{outputDir}\" --name \"{name}\" --no-restore")
+ {
+ WorkingDirectory = RepoRoot,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ };
+
+ using var process = Process.Start(psi)
+ ?? throw new InvalidOperationException($"Failed to start '{DotnetExe} new {template}'.");
+
+ // Hook cancellation to actually terminate the child process. On net472 we don't have
+ // Process.WaitForExitAsync(CancellationToken); a Task.Run+WaitForExit-only approach would
+ // leak the process and let a hung 'dotnet new' burn the 120-min job timeout.
+ using var registration = cancellationToken.Register(() =>
+ {
+ try
+ {
+ if (!process.HasExited)
+ {
+ process.Kill();
+ }
+ }
+ catch
+ {
+ // Process already exited or kill races; nothing actionable.
+ }
+ });
+
+ var stdoutTask = process.StandardOutput.ReadToEndAsync();
+ var stderrTask = process.StandardError.ReadToEndAsync();
+ await Task.Run(() => process.WaitForExit(), CancellationToken.None);
- return solution.GetProjectTemplate(projectTemplate, "FSharp");
+ var stdout = await stdoutTask;
+ var stderr = await stderrTask;
+
+ if (process.ExitCode != 0)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var message = new StringBuilder()
+ .AppendLine($"'{DotnetExe} new {template} -lang F# -o \"{outputDir}\" --name \"{name}\" --no-restore' failed with exit code {process.ExitCode}.")
+ .AppendLine("Standard Output:").AppendLine(stdout)
+ .AppendLine("Standard Error:").AppendLine(stderr)
+ .ToString();
+ throw new InvalidOperationException(message);
+ }
}
public async Task RestoreNuGetPackagesAsync(CancellationToken cancellationToken)
diff --git a/vsintegration/tests/FSharp.Editor.IntegrationTests/WellKnownProjectTemplates.cs b/vsintegration/tests/FSharp.Editor.IntegrationTests/WellKnownProjectTemplates.cs
index 25681a013be..06d3f76c781 100644
--- a/vsintegration/tests/FSharp.Editor.IntegrationTests/WellKnownProjectTemplates.cs
+++ b/vsintegration/tests/FSharp.Editor.IntegrationTests/WellKnownProjectTemplates.cs
@@ -4,9 +4,12 @@
namespace FSharp.Editor.IntegrationTests;
+// SDK short names consumed by `dotnet new` in SolutionExplorerInProcess.AddProjectAsync.
+// The previous values (Microsoft.FSharp.NETCore.*) referenced VS template IDs that never
+// existed in any shipping template package -- the tests have never resolved them.
internal static class WellKnownProjectTemplates
{
- public const string FSharpNetCoreClassLibrary = "Microsoft.FSharp.NETCore.ClassLibrary";
- public const string FSharpNetCoreConsoleApplication = "Microsoft.FSharp.NETCore.ConsoleApplication";
- public const string FSharpNetCoreXUnitTest = "Microsoft.FSharp.NETCore.XUnitTest";
+ public const string FSharpNetCoreClassLibrary = "classlib";
+ public const string FSharpNetCoreConsoleApplication = "console";
+ public const string FSharpNetCoreXUnitTest = "xunit";
}
From 9c6c1367049160e93e89bec0ec954fd2bb0ed12d Mon Sep 17 00:00:00 2001
From: Adam Boniecki <20281641+abonie@users.noreply.github.com>
Date: Thu, 11 Jun 2026 14:25:36 +0200
Subject: [PATCH 07/13] Get correct dll location
---
.../InProcess/SolutionExplorerInProcess.cs | 27 ++++++++++---------
1 file changed, 14 insertions(+), 13 deletions(-)
diff --git a/vsintegration/tests/FSharp.Editor.IntegrationTests/InProcess/SolutionExplorerInProcess.cs b/vsintegration/tests/FSharp.Editor.IntegrationTests/InProcess/SolutionExplorerInProcess.cs
index 430a4940889..a0a08c4d9fa 100644
--- a/vsintegration/tests/FSharp.Editor.IntegrationTests/InProcess/SolutionExplorerInProcess.cs
+++ b/vsintegration/tests/FSharp.Editor.IntegrationTests/InProcess/SolutionExplorerInProcess.cs
@@ -7,7 +7,6 @@
using System.Diagnostics;
using System.IO;
using System.Linq;
-using System.Runtime.CompilerServices;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
@@ -52,16 +51,21 @@ public async Task CreateSingleProjectSolutionAsync(string name, string template,
await AddProjectAsync(name, template, cancellationToken);
}
- // Repo root from compile-time source path -- no runtime resolution needed.
- private static readonly string RepoRoot = Path.GetFullPath(Path.Combine(
- Path.GetDirectoryName(GetSourceFilePath())!, "..", "..", "..", ".."));
-
- // Configuration the test assembly itself was built with (Release on CI, typically Debug locally).
+ // RepoRoot, LocalCompilerConfiguration: derived at runtime from the test assembly's location,
+ // NOT from [CallerFilePath]. Arcade builds with deterministic source-root mapping that rewrites
+ // CallerFilePath to "/_/..." (for symbol-server reproducibility); using it at runtime gives
+ // "D:\_" on Windows CI agents, which then breaks Process.Start(WorkingDirectory) and fsc.dll
+ // path resolution. Assembly.Location IS the real post-build path on disk in all environments.
+ //
// Layout: /artifacts/bin/FSharp.Editor.IntegrationTests///FSharp.Editor.IntegrationTests.dll
- // The synthesized standalone project (CreateStandaloneProjectFile) must reference the matching
- // fsc.dll, otherwise the VS build fails with "Could not find file ...artifacts/bin/fsc///fsc.dll".
- private static readonly string LocalCompilerConfiguration = new DirectoryInfo(
- Path.GetDirectoryName(typeof(SolutionExplorerInProcess).Assembly.Location)!).Parent!.Name;
+ private static readonly string AssemblyDir =
+ Path.GetDirectoryName(typeof(SolutionExplorerInProcess).Assembly.Location)!;
+
+ private static readonly string LocalCompilerConfiguration =
+ new DirectoryInfo(AssemblyDir).Parent!.Name;
+
+ private static readonly string RepoRoot = Path.GetFullPath(
+ Path.Combine(AssemblyDir, "..", "..", "..", "..", ".."));
// Repo-pinned dotnet host installed by Arcade. Falls back to PATH lookup if not present
// (developer scenarios that build the integration project outside the repo's eng infra).
@@ -101,9 +105,6 @@ private static string CreateStandaloneProjectFile()
";
}
- private static string GetSourceFilePath([CallerFilePath] string path = "")
- => path;
-
public async Task CreateSolutionAsync(string solutionName, CancellationToken cancellationToken)
{
await JoinableTaskFactory.SwitchToMainThreadAsync(cancellationToken);
From c5c5ba31935dcfa1b0d21c14175dabfb84f19fac Mon Sep 17 00:00:00 2001
From: Adam Boniecki <20281641+abonie@users.noreply.github.com>
Date: Thu, 11 Jun 2026 14:35:26 +0200
Subject: [PATCH 08/13] Drop VS2022 integration tests
---
.../FSharp.Editor.IntegrationTests/AbstractIntegrationTest.cs | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/vsintegration/tests/FSharp.Editor.IntegrationTests/AbstractIntegrationTest.cs b/vsintegration/tests/FSharp.Editor.IntegrationTests/AbstractIntegrationTest.cs
index 8a42bed252b..10ba847c11e 100644
--- a/vsintegration/tests/FSharp.Editor.IntegrationTests/AbstractIntegrationTest.cs
+++ b/vsintegration/tests/FSharp.Editor.IntegrationTests/AbstractIntegrationTest.cs
@@ -9,7 +9,7 @@
namespace Microsoft.CodeAnalysis.Testing
{
- [IdeSettings(MinVersion = VisualStudioVersion.VS2022)]
+ [IdeSettings(MinVersion = VisualStudioVersion.VS18, MaxVersion = VisualStudioVersion.VS18)]
public abstract class AbstractIntegrationTest : AbstractIdeIntegrationTest
{
protected CancellationToken TestToken => HangMitigatingCancellationToken;
From 63f64bf717ad2258be3ed3cc6255796424a02d08 Mon Sep 17 00:00:00 2001
From: Adam Boniecki <20281641+abonie@users.noreply.github.com>
Date: Thu, 11 Jun 2026 15:40:14 +0200
Subject: [PATCH 09/13] Fix tests regressions
---
.../FSharp.Editor.IntegrationTests/BuildProjectTests.cs | 2 +-
.../DebuggingSequencePointTests.cs | 5 +++++
.../FSharp.Editor.IntegrationTests/GoToDefinitionTests.cs | 5 +++++
3 files changed, 11 insertions(+), 1 deletion(-)
diff --git a/vsintegration/tests/FSharp.Editor.IntegrationTests/BuildProjectTests.cs b/vsintegration/tests/FSharp.Editor.IntegrationTests/BuildProjectTests.cs
index ca89983189e..6605eda0199 100644
--- a/vsintegration/tests/FSharp.Editor.IntegrationTests/BuildProjectTests.cs
+++ b/vsintegration/tests/FSharp.Editor.IntegrationTests/BuildProjectTests.cs
@@ -43,7 +43,7 @@ module Test
let answer =
""";
var expectedBuildSummary = "========== Build: 0 succeeded, 1 failed, 0 up-to-date, 0 skipped ==========";
- var expectedError = "(Compiler) Library.fs(3, 1): error FS0010: Incomplete structured construct at or before this point in binding";
+ var expectedError = "(Fsc) Library.fs(3, 1): error FS0010: Incomplete structured construct at or before this point in binding";
await SolutionExplorer.CreateSingleProjectSolutionAsync("Library", template, TestToken);
await SolutionExplorer.RestoreNuGetPackagesAsync(TestToken);
diff --git a/vsintegration/tests/FSharp.Editor.IntegrationTests/DebuggingSequencePointTests.cs b/vsintegration/tests/FSharp.Editor.IntegrationTests/DebuggingSequencePointTests.cs
index 7d902dbab6d..ea5adf022c9 100644
--- a/vsintegration/tests/FSharp.Editor.IntegrationTests/DebuggingSequencePointTests.cs
+++ b/vsintegration/tests/FSharp.Editor.IntegrationTests/DebuggingSequencePointTests.cs
@@ -90,6 +90,11 @@ private async Task PrepareProjectAndBuildAsync()
var buildSummary = await SolutionExplorer.BuildSolutionAsync(TestToken);
Assert.NotNull(buildSummary);
Assert.Contains("Build: 1 succeeded, 0 failed", string.Join(Environment.NewLine, buildSummary));
+
+ // BuildSolutionAsync leaves the Build Output pane as the active text view; re-open Program.fs
+ // so the subsequent PlaceCaretAsync / ToggleBreakpointAtMarkerAsync operate on the F# source
+ // (rather than searching the build log for "BP_..." markers).
+ await SolutionExplorer.OpenFileAsync(ProjectName, "Program.fs", TestToken);
}
private static string GetFixturePath()
diff --git a/vsintegration/tests/FSharp.Editor.IntegrationTests/GoToDefinitionTests.cs b/vsintegration/tests/FSharp.Editor.IntegrationTests/GoToDefinitionTests.cs
index 66bb33eccd9..eefd0c1a678 100644
--- a/vsintegration/tests/FSharp.Editor.IntegrationTests/GoToDefinitionTests.cs
+++ b/vsintegration/tests/FSharp.Editor.IntegrationTests/GoToDefinitionTests.cs
@@ -30,6 +30,9 @@ module Test
await Editor.SetTextAsync(code, TestToken);
await Editor.PlaceCaretAsync("add 1", TestToken);
+ // GoToDefn needs the F# language service to have typechecked the file; without an explicit
+ // wait the command is a no-op and Assert.Contains below fails with the caret line unchanged.
+ await Workspace.WaitForProjectSystemAsync(TestToken);
await Shell.ExecuteCommandAsync(VSStd97CmdID.GotoDefn, TestToken);
var actualText = await Editor.GetCurrentLineTextAsync(TestToken);
@@ -73,6 +76,7 @@ let id (t: SomeType) = t
await SolutionExplorer.OpenFileAsync("Library", "Module.fsi", TestToken);
await Editor.PlaceCaretAsync("SomeType ->", TestToken);
+ await Workspace.WaitForProjectSystemAsync(TestToken);
await Shell.ExecuteCommandAsync(VSStd97CmdID.GotoDefn, TestToken);
var expectedText = "type SomeType =";
var expectedWindow = "Module.fsi";
@@ -83,6 +87,7 @@ let id (t: SomeType) = t
await SolutionExplorer.OpenFileAsync("Library", "Module.fs", TestToken);
await Editor.PlaceCaretAsync("SomeType)", TestToken);
+ await Workspace.WaitForProjectSystemAsync(TestToken);
await Shell.ExecuteCommandAsync(VSStd97CmdID.GotoDefn, TestToken);
expectedText = "type SomeType =";
expectedWindow = "Module.fs";
From c805d381ae0716f4aec5d4fb2ccae24a7ac715a2 Mon Sep 17 00:00:00 2001
From: Adam Boniecki <20281641+abonie@users.noreply.github.com>
Date: Thu, 11 Jun 2026 16:54:37 +0200
Subject: [PATCH 10/13] Add wait for gotodefinition tests
---
.../GoToDefinitionTests.cs | 19 +++++++++++++------
1 file changed, 13 insertions(+), 6 deletions(-)
diff --git a/vsintegration/tests/FSharp.Editor.IntegrationTests/GoToDefinitionTests.cs b/vsintegration/tests/FSharp.Editor.IntegrationTests/GoToDefinitionTests.cs
index eefd0c1a678..a5a141de5bd 100644
--- a/vsintegration/tests/FSharp.Editor.IntegrationTests/GoToDefinitionTests.cs
+++ b/vsintegration/tests/FSharp.Editor.IntegrationTests/GoToDefinitionTests.cs
@@ -3,6 +3,7 @@
// See the LICENSE file in the project root for more information.
using Microsoft.CodeAnalysis.Testing;
+using System;
using System.Threading.Tasks;
using Xunit;
using static Microsoft.VisualStudio.VSConstants;
@@ -11,6 +12,14 @@ namespace FSharp.Editor.IntegrationTests;
public class GoToDefinitionTests : AbstractIntegrationTest
{
+ // Roslyn's workspace propagates buffer edits to its solution snapshot asynchronously, and the
+ // F# checker schedules its own typecheck on top. WaitForProjectSystemAsync only covers VS
+ // project-system loading -- not these two async hops -- so GoToDefn invoked too quickly after
+ // SetTextAsync / OpenFileAsync sees a stale Document and no-ops. A short delay is the cheapest
+ // pragmatic bridge until a proper Roslyn-style WaitForAsyncOperationsAsync(FeatureAttribute.Workspace)
+ // partial is wired up here.
+ private static readonly TimeSpan FSharpCheckerSettleDelay = TimeSpan.FromSeconds(2);
+
[IdeFact]
public async Task GoesToDefinition()
{
@@ -28,11 +37,9 @@ module Test
await SolutionExplorer.CreateSingleProjectSolutionAsync("Library", template, TestToken);
await SolutionExplorer.RestoreNuGetPackagesAsync(TestToken);
await Editor.SetTextAsync(code, TestToken);
-
+ await Task.Delay(FSharpCheckerSettleDelay, TestToken);
+
await Editor.PlaceCaretAsync("add 1", TestToken);
- // GoToDefn needs the F# language service to have typechecked the file; without an explicit
- // wait the command is a no-op and Assert.Contains below fails with the caret line unchanged.
- await Workspace.WaitForProjectSystemAsync(TestToken);
await Shell.ExecuteCommandAsync(VSStd97CmdID.GotoDefn, TestToken);
var actualText = await Editor.GetCurrentLineTextAsync(TestToken);
@@ -75,8 +82,8 @@ let id (t: SomeType) = t
await SolutionExplorer.BuildSolutionAsync(TestToken);
await SolutionExplorer.OpenFileAsync("Library", "Module.fsi", TestToken);
+ await Task.Delay(FSharpCheckerSettleDelay, TestToken);
await Editor.PlaceCaretAsync("SomeType ->", TestToken);
- await Workspace.WaitForProjectSystemAsync(TestToken);
await Shell.ExecuteCommandAsync(VSStd97CmdID.GotoDefn, TestToken);
var expectedText = "type SomeType =";
var expectedWindow = "Module.fsi";
@@ -86,8 +93,8 @@ let id (t: SomeType) = t
Assert.Equal(expectedWindow, actualWindow);
await SolutionExplorer.OpenFileAsync("Library", "Module.fs", TestToken);
+ await Task.Delay(FSharpCheckerSettleDelay, TestToken);
await Editor.PlaceCaretAsync("SomeType)", TestToken);
- await Workspace.WaitForProjectSystemAsync(TestToken);
await Shell.ExecuteCommandAsync(VSStd97CmdID.GotoDefn, TestToken);
expectedText = "type SomeType =";
expectedWindow = "Module.fs";
From 28ae1d64b7f57585493601688a59b2922f613f60 Mon Sep 17 00:00:00 2001
From: Adam Boniecki <20281641+abonie@users.noreply.github.com>
Date: Thu, 11 Jun 2026 17:55:46 +0200
Subject: [PATCH 11/13] Poll and retry for lightbulb tests
---
.../InProcess/EditorInProcess.cs | 55 ++++++++++++++++---
1 file changed, 46 insertions(+), 9 deletions(-)
diff --git a/vsintegration/tests/FSharp.Editor.IntegrationTests/InProcess/EditorInProcess.cs b/vsintegration/tests/FSharp.Editor.IntegrationTests/InProcess/EditorInProcess.cs
index 2e9009ba9c6..01d4717527a 100644
--- a/vsintegration/tests/FSharp.Editor.IntegrationTests/InProcess/EditorInProcess.cs
+++ b/vsintegration/tests/FSharp.Editor.IntegrationTests/InProcess/EditorInProcess.cs
@@ -75,20 +75,57 @@ public async Task PlaceCaretAsync(string marker, CancellationToken cancellationT
public async Task> InvokeCodeActionListAsync(CancellationToken cancellationToken)
{
- await JoinableTaskFactory.SwitchToMainThreadAsync(cancellationToken);
+ // Poll-and-retry: the lightbulb session can race with F# diagnostics computation in two ways:
+ // - broker.GetSession(view) returns null when no session is active yet (NRE on cast).
+ // - The session opens but self-dismisses before reaching Completed/CompletedWithoutData,
+ // producing TaskCanceledException from LightBulbHelper.WaitForItemsAsync.
+ // Each retry posts a fresh ShowQuickFixes command (creating a new session) and tolerates
+ // both failure modes. Bounded to ~5 seconds; the outer test ct still caps total wait.
+ const int MaxAttempts = 20;
+ var attemptDelay = TimeSpan.FromMilliseconds(250);
var shell = await GetRequiredGlobalServiceAsync(cancellationToken);
+ var broker = await GetComponentModelServiceAsync(cancellationToken);
var cmdGroup = typeof(VSConstants.VSStd14CmdID).GUID;
- var cmdExecOpt = OLECMDEXECOPT.OLECMDEXECOPT_DONTPROMPTUSER;
-
var cmdID = VSConstants.VSStd14CmdID.ShowQuickFixes;
- object? obj = null;
- shell.PostExecCommand(cmdGroup, (uint)cmdID, (uint)cmdExecOpt, ref obj);
- var view = await GetActiveTextViewAsync(cancellationToken);
- var broker = await GetComponentModelServiceAsync(cancellationToken);
+ for (var attempt = 1; attempt <= MaxAttempts; attempt++)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ await JoinableTaskFactory.SwitchToMainThreadAsync(cancellationToken);
+
+ object? obj = null;
+ shell.PostExecCommand(cmdGroup, (uint)cmdID, (uint)OLECMDEXECOPT.OLECMDEXECOPT_DONTPROMPTUSER, ref obj);
+ var view = await GetActiveTextViewAsync(cancellationToken);
+
+ try
+ {
+ var lightbulbs = await LightBulbHelper.WaitForItemsAsync(broker, view, cancellationToken);
+ if (lightbulbs is not null && System.Linq.Enumerable.Any(lightbulbs))
+ {
+ return lightbulbs;
+ }
+ }
+ catch (NullReferenceException) when (attempt < MaxAttempts)
+ {
+ // broker.GetSession(view) returned null; F# analyzer hasn't surfaced a session yet.
+ }
+ catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested && attempt < MaxAttempts)
+ {
+ // Session opened but was dismissed before producing actions; try again.
+ }
+
+ await Task.Delay(attemptDelay, cancellationToken);
+ }
- var lightbulbs = await LightBulbHelper.WaitForItemsAsync(broker, view, cancellationToken);
- return lightbulbs;
+ // Final attempt: let the underlying exception propagate so the test failure message
+ // points at the actual root cause (NRE / TaskCanceled) rather than a vague timeout.
+ await JoinableTaskFactory.SwitchToMainThreadAsync(cancellationToken);
+ {
+ object? obj = null;
+ shell.PostExecCommand(cmdGroup, (uint)cmdID, (uint)OLECMDEXECOPT.OLECMDEXECOPT_DONTPROMPTUSER, ref obj);
+ var view = await GetActiveTextViewAsync(cancellationToken);
+ return await LightBulbHelper.WaitForItemsAsync(broker, view, cancellationToken);
+ }
}
}
From 5fbaa7f71c18923a82cd9bb896b3c68a916cce26 Mon Sep 17 00:00:00 2001
From: Adam Boniecki <20281641+abonie@users.noreply.github.com>
Date: Thu, 11 Jun 2026 17:56:09 +0200
Subject: [PATCH 12/13] More telemetry for debugging tests
---
.../DebuggingSequencePointTests.cs | 9 +++++++--
.../InProcess/ShellInProcess_Debugging.cs | 12 ++++++++---
.../SolutionExplorerInProcess_Files.cs | 20 +++++++++++++++++++
3 files changed, 36 insertions(+), 5 deletions(-)
diff --git a/vsintegration/tests/FSharp.Editor.IntegrationTests/DebuggingSequencePointTests.cs b/vsintegration/tests/FSharp.Editor.IntegrationTests/DebuggingSequencePointTests.cs
index ea5adf022c9..5aa5f121c01 100644
--- a/vsintegration/tests/FSharp.Editor.IntegrationTests/DebuggingSequencePointTests.cs
+++ b/vsintegration/tests/FSharp.Editor.IntegrationTests/DebuggingSequencePointTests.cs
@@ -83,9 +83,14 @@ private async Task PrepareProjectAndBuildAsync()
await SolutionExplorer.CreateSingleProjectSolutionAsync(ProjectName, SolutionExplorerInProcess.ExistingProjectTemplate, TestToken);
await SolutionExplorer.SetStartupProjectAsync(ProjectName, TestToken);
+ // Write the fixture directly to disk *before* opening it in VS, instead of using
+ // SetTextAsync + "File.SaveAll" command. Reason: the debugger binds breakpoints by
+ // matching the PDB's recorded source-file hash against the hash of Program.fs on disk.
+ // If the edit-buffer is built first and SaveAll lands a moment later, the PDB hash
+ // points at one snapshot while disk holds another -- breakpoints stay unbound
+ // (children=0) and never fire. Writing to disk first keeps the two in lockstep.
+ await SolutionExplorer.WriteFileAsync(ProjectName, "Program.fs", File.ReadAllText(GetFixturePath()), TestToken);
await SolutionExplorer.OpenFileAsync(ProjectName, "Program.fs", TestToken);
- await Editor.SetTextAsync(File.ReadAllText(GetFixturePath()), TestToken);
- await Shell.ExecuteCommandAsync("File.SaveAll", TestToken);
var buildSummary = await SolutionExplorer.BuildSolutionAsync(TestToken);
Assert.NotNull(buildSummary);
diff --git a/vsintegration/tests/FSharp.Editor.IntegrationTests/InProcess/ShellInProcess_Debugging.cs b/vsintegration/tests/FSharp.Editor.IntegrationTests/InProcess/ShellInProcess_Debugging.cs
index 2dac220006f..d123851d366 100644
--- a/vsintegration/tests/FSharp.Editor.IntegrationTests/InProcess/ShellInProcess_Debugging.cs
+++ b/vsintegration/tests/FSharp.Editor.IntegrationTests/InProcess/ShellInProcess_Debugging.cs
@@ -84,10 +84,12 @@ public async Task WaitForBreakpointHitAsync(TimeSpan timeout, bool continueOnSte
dbg.Go(true);
if (seenRun && mode == EnvDTE.dbgDebugMode.dbgDesignMode)
- throw new InvalidOperationException("Debug session ended before breakpoint hit.");
+ throw new InvalidOperationException(
+ $"Debug session ended before breakpoint hit. Breakpoints={BreakpointSummary(dbg)}.");
if (sw.Elapsed > timeout)
- throw new TimeoutException($"No breakpoint hit after {timeout}. Mode={mode}; Breakpoints={BreakpointSummary(dbg)}.");
+ throw new TimeoutException(
+ $"No breakpoint hit after {timeout}. Mode={mode}; Breakpoints={BreakpointSummary(dbg)}.");
await Task.Delay(100, cancellationToken);
}
@@ -122,7 +124,11 @@ private static string BreakpointSummary(EnvDTE.Debugger dbg)
{
var children = 0;
foreach (EnvDTE.Breakpoint _ in bp.Children) children++;
- items.Add($"{Path.GetFileName(bp.File)}:{bp.FileLine}(children={children})");
+ // CurrentHits + Children counts together let us distinguish unbound (children=0, hits=0)
+ // from "bound but never executed" (children>0, hits=0) from "bound and hit" (hits>0).
+ // Unbound means the PDB has no IL location for this source line; the disk-source hash
+ // didn't match the PDB's recorded hash; or the loaded module doesn't have this code.
+ items.Add($"{Path.GetFileName(bp.File)}:{bp.FileLine}(children={children},hits={bp.CurrentHits})");
}
return items.Count == 0 ? "none" : string.Join(",", items);
}
diff --git a/vsintegration/tests/FSharp.Editor.IntegrationTests/InProcess/SolutionExplorerInProcess_Files.cs b/vsintegration/tests/FSharp.Editor.IntegrationTests/InProcess/SolutionExplorerInProcess_Files.cs
index f639decdb82..aa6d7ad0a9a 100644
--- a/vsintegration/tests/FSharp.Editor.IntegrationTests/InProcess/SolutionExplorerInProcess_Files.cs
+++ b/vsintegration/tests/FSharp.Editor.IntegrationTests/InProcess/SolutionExplorerInProcess_Files.cs
@@ -51,6 +51,26 @@ public async Task AddFileAsync(string projectName, string fileName, string conte
_ = project.ProjectItems.AddFromFile(filePath);
}
+ // Overwrites the on-disk content of a file that is already a member of the project.
+ // Use this for tests where the build output / PDB must reflect the file content exactly
+ // (e.g., debugger breakpoint binding requires the PDB's source-file hash to match the
+ // hash of the file on disk -- the SetTextAsync + "File.SaveAll" command pattern can race
+ // with the build and leave the disk content stale, so the PDB ends up pointing at a
+ // different source hash and breakpoints never bind).
+ public async Task WriteFileAsync(string projectName, string fileName, string contents, CancellationToken cancellationToken)
+ {
+ await JoinableTaskFactory.SwitchToMainThreadAsync(cancellationToken);
+
+ var project = await GetProjectAsync(projectName, cancellationToken);
+ var projectDirectory = Path.GetDirectoryName(project.FullName);
+ var filePath = Path.Combine(projectDirectory, fileName);
+ if (!File.Exists(filePath))
+ {
+ throw new FileNotFoundException($"WriteFileAsync expects '{fileName}' to already exist in project '{projectName}' (use AddFileAsync to create new files).", filePath);
+ }
+ File.WriteAllText(filePath, contents);
+ }
+
public async Task RenameFileAsync(string projectName, string oldFileName, string newFileName, CancellationToken cancellationToken)
{
await JoinableTaskFactory.SwitchToMainThreadAsync(cancellationToken);
From 2458906478742f1a911c604ced70e8281c94c2eb Mon Sep 17 00:00:00 2001
From: Adam Boniecki <20281641+abonie@users.noreply.github.com>
Date: Thu, 11 Jun 2026 18:52:26 +0200
Subject: [PATCH 13/13] Rework waiting for lightbulb
---
.../InProcess/EditorInProcess.cs | 64 +++++++------------
1 file changed, 23 insertions(+), 41 deletions(-)
diff --git a/vsintegration/tests/FSharp.Editor.IntegrationTests/InProcess/EditorInProcess.cs b/vsintegration/tests/FSharp.Editor.IntegrationTests/InProcess/EditorInProcess.cs
index 01d4717527a..2dbde4de1d2 100644
--- a/vsintegration/tests/FSharp.Editor.IntegrationTests/InProcess/EditorInProcess.cs
+++ b/vsintegration/tests/FSharp.Editor.IntegrationTests/InProcess/EditorInProcess.cs
@@ -75,57 +75,39 @@ public async Task PlaceCaretAsync(string marker, CancellationToken cancellationT
public async Task> InvokeCodeActionListAsync(CancellationToken cancellationToken)
{
- // Poll-and-retry: the lightbulb session can race with F# diagnostics computation in two ways:
- // - broker.GetSession(view) returns null when no session is active yet (NRE on cast).
- // - The session opens but self-dismisses before reaching Completed/CompletedWithoutData,
- // producing TaskCanceledException from LightBulbHelper.WaitForItemsAsync.
- // Each retry posts a fresh ShowQuickFixes command (creating a new session) and tolerates
- // both failure modes. Bounded to ~5 seconds; the outer test ct still caps total wait.
- const int MaxAttempts = 20;
- var attemptDelay = TimeSpan.FromMilliseconds(250);
+ await JoinableTaskFactory.SwitchToMainThreadAsync(cancellationToken);
var shell = await GetRequiredGlobalServiceAsync(cancellationToken);
- var broker = await GetComponentModelServiceAsync(cancellationToken);
var cmdGroup = typeof(VSConstants.VSStd14CmdID).GUID;
var cmdID = VSConstants.VSStd14CmdID.ShowQuickFixes;
- for (var attempt = 1; attempt <= MaxAttempts; attempt++)
- {
- cancellationToken.ThrowIfCancellationRequested();
- await JoinableTaskFactory.SwitchToMainThreadAsync(cancellationToken);
+ // Post ShowQuickFixes once. PostExecCommand is fire-and-forget; the broker spins up the
+ // session asynchronously after F# diagnostics surface, so we have to wait for the session
+ // to materialize before LightBulbHelper.WaitForItemsAsync can subscribe to its events.
+ // Repeated PostExecCommand calls would dismiss any in-flight session and reset the race.
+ object? obj = null;
+ shell.PostExecCommand(cmdGroup, (uint)cmdID, (uint)OLECMDEXECOPT.OLECMDEXECOPT_DONTPROMPTUSER, ref obj);
- object? obj = null;
- shell.PostExecCommand(cmdGroup, (uint)cmdID, (uint)OLECMDEXECOPT.OLECMDEXECOPT_DONTPROMPTUSER, ref obj);
- var view = await GetActiveTextViewAsync(cancellationToken);
+ var view = await GetActiveTextViewAsync(cancellationToken);
+ var broker = await GetComponentModelServiceAsync(cancellationToken);
- try
- {
- var lightbulbs = await LightBulbHelper.WaitForItemsAsync(broker, view, cancellationToken);
- if (lightbulbs is not null && System.Linq.Enumerable.Any(lightbulbs))
- {
- return lightbulbs;
- }
- }
- catch (NullReferenceException) when (attempt < MaxAttempts)
- {
- // broker.GetSession(view) returned null; F# analyzer hasn't surfaced a session yet.
- }
- catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested && attempt < MaxAttempts)
+ // Poll for the session to appear (up to ~5 s). Without this wait, the F#-analyzer-driven
+ // code fixes (UnusedOpenDeclarations, AddMissingFunKeyword) NRE because broker.GetSession
+ // returns null before the lightbulb session is ready -- LightBulbHelper.WaitForItemsAsync
+ // then casts null to IAsyncLightBulbSession and subscribes to its events.
+ var sessionTimeout = TimeSpan.FromSeconds(5);
+ var sw = System.Diagnostics.Stopwatch.StartNew();
+ while (broker.GetSession(view) is null)
+ {
+ if (sw.Elapsed > sessionTimeout)
{
- // Session opened but was dismissed before producing actions; try again.
+ // Final attempt -- let LightBulbHelper's existing NRE propagate so the test failure
+ // points at the actual root cause, not a manufactured timeout exception.
+ break;
}
-
- await Task.Delay(attemptDelay, cancellationToken);
+ await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken);
}
- // Final attempt: let the underlying exception propagate so the test failure message
- // points at the actual root cause (NRE / TaskCanceled) rather than a vague timeout.
- await JoinableTaskFactory.SwitchToMainThreadAsync(cancellationToken);
- {
- object? obj = null;
- shell.PostExecCommand(cmdGroup, (uint)cmdID, (uint)OLECMDEXECOPT.OLECMDEXECOPT_DONTPROMPTUSER, ref obj);
- var view = await GetActiveTextViewAsync(cancellationToken);
- return await LightBulbHelper.WaitForItemsAsync(broker, view, cancellationToken);
- }
+ return await LightBulbHelper.WaitForItemsAsync(broker, view, cancellationToken);
}
}