From ec122d90ec56faf31ea1b243e0a7cb6e62c4079d Mon Sep 17 00:00:00 2001 From: Petr Date: Tue, 2 May 2023 15:04:20 +0200 Subject: [PATCH] Integration tests for code actions (#15142) --- .../CodeActionTests.cs | 103 ++++++++++++++++++ .../Helpers/LightBulbHelper.cs | 66 +++++++++++ .../InProcess/EditorInProcess.cs | 23 ++++ 3 files changed, 192 insertions(+) create mode 100644 vsintegration/tests/FSharp.Editor.IntegrationTests/CodeActionTests.cs create mode 100644 vsintegration/tests/FSharp.Editor.IntegrationTests/Helpers/LightBulbHelper.cs diff --git a/vsintegration/tests/FSharp.Editor.IntegrationTests/CodeActionTests.cs b/vsintegration/tests/FSharp.Editor.IntegrationTests/CodeActionTests.cs new file mode 100644 index 0000000000..a8d2cf8162 --- /dev/null +++ b/vsintegration/tests/FSharp.Editor.IntegrationTests/CodeActionTests.cs @@ -0,0 +1,103 @@ +// 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 file in the project root for more information. + +using Microsoft.CodeAnalysis.Testing; +using System.Linq; +using System.Threading.Tasks; +using Xunit; + +namespace FSharp.Editor.IntegrationTests; + +public class CodeActionTests : AbstractIntegrationTest +{ + [IdeFact] + public async Task UnusedOpenDeclarations() + { + var template = WellKnownProjectTemplates.FSharpNetCoreClassLibrary; + + var code = """ +module Library + +open System + +let x = 42 +"""; + + await SolutionExplorer.CreateSingleProjectSolutionAsync("Library", template, TestToken); + await SolutionExplorer.RestoreNuGetPackagesAsync(TestToken); + await Editor.SetTextAsync(code, TestToken); + await Editor.PlaceCaretAsync("open System", TestToken); + + await Workspace.WaitForProjectSystemAsync(TestToken); + var codeActions = await Editor.InvokeCodeActionListAsync(TestToken); + await Workspace.WaitForProjectSystemAsync(TestToken); + + Assert.Single(codeActions); + var actionSet = codeActions.Single(); + Assert.Equal("CodeFix", actionSet.CategoryName); + + Assert.Single(actionSet.Actions); + var codeFix = actionSet.Actions.Single(); + Assert.Equal("Remove unused open declarations", codeFix.DisplayText); + } + + [IdeFact] + public async Task AddMissingFunKeyword() + { + var template = WellKnownProjectTemplates.FSharpNetCoreClassLibrary; + + var code = """ +module Library + +let original = [] +let transformed = original |> List.map (x -> x) +"""; + + await SolutionExplorer.CreateSingleProjectSolutionAsync("Library", template, TestToken); + await SolutionExplorer.RestoreNuGetPackagesAsync(TestToken); + await Editor.SetTextAsync(code, TestToken); + await Editor.PlaceCaretAsync("->", TestToken); + + await Workspace.WaitForProjectSystemAsync(TestToken); + var codeActions = await Editor.InvokeCodeActionListAsync(TestToken); + await Workspace.WaitForProjectSystemAsync(TestToken); + + Assert.Single(codeActions); + var actionSet = codeActions.Single(); + Assert.Equal("ErrorFix", actionSet.CategoryName); + + Assert.Single(actionSet.Actions); + var errorFix = actionSet.Actions.Single(); + Assert.Equal("Add missing 'fun' keyword", errorFix.DisplayText); + } + + [IdeFact] + public async Task AddNewKeywordToDisposables() + { + var template = WellKnownProjectTemplates.FSharpNetCoreClassLibrary; + + var code = """ +module Library + +let sr = System.IO.StreamReader("") +"""; + + await SolutionExplorer.CreateSingleProjectSolutionAsync("Library", template, TestToken); + await SolutionExplorer.RestoreNuGetPackagesAsync(TestToken); + await Editor.SetTextAsync(code, TestToken); + await Editor.PlaceCaretAsync("let sr", TestToken); + + await Workspace.WaitForProjectSystemAsync(TestToken); + var codeActions = await Editor.InvokeCodeActionListAsync(TestToken); + await Workspace.WaitForProjectSystemAsync(TestToken); + + Assert.Single(codeActions); + var actionSet = codeActions.Single(); + Assert.Equal("CodeFix", actionSet.CategoryName); + + Assert.Single(actionSet.Actions); + var codeFix = actionSet.Actions.Single(); + Assert.Equal("Add 'new' keyword", codeFix.DisplayText); + } +} diff --git a/vsintegration/tests/FSharp.Editor.IntegrationTests/Helpers/LightBulbHelper.cs b/vsintegration/tests/FSharp.Editor.IntegrationTests/Helpers/LightBulbHelper.cs new file mode 100644 index 0000000000..d40528cea7 --- /dev/null +++ b/vsintegration/tests/FSharp.Editor.IntegrationTests/Helpers/LightBulbHelper.cs @@ -0,0 +1,66 @@ +// 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 file in the project root for more information. + +using Microsoft.VisualStudio.Language.Intellisense; +using Microsoft.VisualStudio.Text.Editor; +using Microsoft.VisualStudio.Threading; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace FSharp.Editor.IntegrationTests.Helpers +{ + // I stole this voodoo from Razor and removed the obscurest bits + internal static class LightBulbHelper + { + public static async Task> WaitForItemsAsync( + ILightBulbBroker broker, + IWpfTextView view, + CancellationToken cancellationToken) + { + var activeSession = broker.GetSession(view); + var asyncSession = (IAsyncLightBulbSession)activeSession; + var tcs = new TaskCompletionSource>(); + + void Handler(object s, SuggestedActionsUpdatedArgs e) + { + // ignore these. we care about when the lightbulb items are all completed. + if (e.Status == QuerySuggestedActionCompletionStatus.InProgress) + { + return; + } + + if (e.Status == QuerySuggestedActionCompletionStatus.Completed || + e.Status == QuerySuggestedActionCompletionStatus.CompletedWithoutData) + { + tcs.SetResult(e.ActionSets); + } + else + { + tcs.SetException(new InvalidOperationException($"Light bulb transitioned to non-complete state: {e.Status}")); + } + + asyncSession.SuggestedActionsUpdated -= Handler; + } + + asyncSession.SuggestedActionsUpdated += Handler; + + asyncSession.Dismissed += (_, _) => tcs.TrySetCanceled(new CancellationToken(true)); + + if (asyncSession.IsDismissed) + { + tcs.TrySetCanceled(new CancellationToken(true)); + } + + // Calling PopulateWithDataAsync ensures the underlying session will call SuggestedActionsUpdated at least once + // with the latest data computed. This is needed so that if the lightbulb computation is already complete + // that we hear about the results. + await asyncSession.PopulateWithDataAsync(overrideRequestedActionCategories: null, operationContext: null).ConfigureAwait(false); + + return await tcs.Task.WithCancellation(cancellationToken); + } + } +} diff --git a/vsintegration/tests/FSharp.Editor.IntegrationTests/InProcess/EditorInProcess.cs b/vsintegration/tests/FSharp.Editor.IntegrationTests/InProcess/EditorInProcess.cs index 488459879b..2e9009ba9c 100644 --- a/vsintegration/tests/FSharp.Editor.IntegrationTests/InProcess/EditorInProcess.cs +++ b/vsintegration/tests/FSharp.Editor.IntegrationTests/InProcess/EditorInProcess.cs @@ -3,9 +3,13 @@ // See the LICENSE file in the project root for more information. using System; +using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using FSharp.Editor.IntegrationTests.Extensions; +using FSharp.Editor.IntegrationTests.Helpers; +using Microsoft.VisualStudio.Language.Intellisense; +using Microsoft.VisualStudio.OLE.Interop; using Microsoft.VisualStudio.Shell.Interop; using Microsoft.VisualStudio.Text; @@ -68,4 +72,23 @@ internal partial class EditorInProcess view.Selection.Clear(); } + + public async Task> InvokeCodeActionListAsync(CancellationToken cancellationToken) + { + await JoinableTaskFactory.SwitchToMainThreadAsync(cancellationToken); + + var shell = await GetRequiredGlobalServiceAsync(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); + + var lightbulbs = await LightBulbHelper.WaitForItemsAsync(broker, view, cancellationToken); + return lightbulbs; + } }