diff --git a/eng/Versions.props b/eng/Versions.props index 8690b4e9906..93cfd50bbfe 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -104,6 +104,7 @@ 17.5.10-alpha 17.4.0-preview-22469-04 + $(RoslynVersion) $(RoslynVersion) $(RoslynVersion) $(RoslynVersion) @@ -163,6 +164,7 @@ 0.1.149-beta $(MicrosoftVisualStudioExtensibilityTestingVersion) $(MicrosoftVisualStudioExtensibilityTestingVersion) + 1.0.7 $(MicrosoftVisualStudioThreadingPackagesVersion) diff --git a/vsintegration/tests/FSharp.Editor.IntegrationTests/FSharp.Editor.IntegrationTests.csproj b/vsintegration/tests/FSharp.Editor.IntegrationTests/FSharp.Editor.IntegrationTests.csproj index f52bfdf9bad..089f38561b8 100644 --- a/vsintegration/tests/FSharp.Editor.IntegrationTests/FSharp.Editor.IntegrationTests.csproj +++ b/vsintegration/tests/FSharp.Editor.IntegrationTests/FSharp.Editor.IntegrationTests.csproj @@ -21,10 +21,15 @@ + + + + + diff --git a/vsintegration/tests/FSharp.Editor.IntegrationTests/InProcess/EditorInProcess.cs b/vsintegration/tests/FSharp.Editor.IntegrationTests/InProcess/EditorInProcess.cs index 004953d5797..2eb2490d5cb 100644 --- a/vsintegration/tests/FSharp.Editor.IntegrationTests/InProcess/EditorInProcess.cs +++ b/vsintegration/tests/FSharp.Editor.IntegrationTests/InProcess/EditorInProcess.cs @@ -4,7 +4,14 @@ using System.Threading; using System.Threading.Tasks; +using Microsoft.CodeAnalysis.Editor.Shared.Extensions; +using Microsoft.VisualStudio.Editor; +using Microsoft.VisualStudio.Shell; +using Microsoft.VisualStudio.Shell.Interop; using Microsoft.VisualStudio.Text; +using Microsoft.VisualStudio.Threading; +using FSharp.Editor.IntegrationTests; +using Microsoft.VisualStudio.Text.Editor; namespace Microsoft.VisualStudio.Extensibility.Testing; @@ -28,4 +35,28 @@ public async Task SetTextAsync(string text, CancellationToken cancellationToken) var replacementSpan = new SnapshotSpan(textSnapshot, 0, textSnapshot.Length); view.TextBuffer.Replace(replacementSpan, text); } + + public async Task WaitForEditorOperationsAsync(CancellationToken cancellationToken) + { + await JoinableTaskFactory.SwitchToMainThreadAsync(cancellationToken); + + var shell = await GetRequiredGlobalServiceAsync(cancellationToken); + if (shell.IsPackageLoaded(DefGuidList.guidEditorPkg, out var editorPackage) == VSConstants.S_OK) + { + var asyncPackage = (AsyncPackage)editorPackage; + var collection = asyncPackage.GetPropertyValue("JoinableTaskCollection"); + await collection.JoinTillEmptyAsync(cancellationToken); + } + } + + public async Task GetSelectedTextAsync(CancellationToken cancellationToken) + { + await JoinableTaskFactory.SwitchToMainThreadAsync(cancellationToken); + + var view = await TestServices.Editor.GetActiveTextViewAsync(cancellationToken); + var subjectBuffer = ITextViewExtensions.GetBufferContainingCaret(view); + + var selectedSpan = view.Selection.SelectedSpans[0]; + return subjectBuffer.CurrentSnapshot.GetText(selectedSpan); + } } diff --git a/vsintegration/tests/FSharp.Editor.IntegrationTests/InProcess/InputInProcess.cs b/vsintegration/tests/FSharp.Editor.IntegrationTests/InProcess/InputInProcess.cs new file mode 100644 index 00000000000..fd11cf30ae3 --- /dev/null +++ b/vsintegration/tests/FSharp.Editor.IntegrationTests/InProcess/InputInProcess.cs @@ -0,0 +1,55 @@ +// 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 System.Threading; +using System.Threading.Tasks; +using Microsoft.VisualStudio.Shell; +using Microsoft.VisualStudio.Threading; +using WindowsInput; +using WindowsInput.Native; + +namespace Microsoft.VisualStudio.Extensibility.Testing; + +[TestService] +internal partial class InputInProcess +{ + internal async Task SendToNavigateToAsync(InputKey[] keys, CancellationToken cancellationToken) + { + // AbstractSendKeys runs synchronously, so switch to a background thread before the call + await TaskScheduler.Default; + + // Take no direct action regarding activation, but assert the correct item already has focus + await TestServices.JoinableTaskFactory.RunAsync(async () => + { + await TestServices.JoinableTaskFactory.SwitchToMainThreadAsync(); + }); + + var inputSimulator = new InputSimulator(); + foreach (var key in keys) + { + // If it is enter key, we need to wait for search item shows up in the search dialog. + if (key.VirtualKeyCode == VirtualKeyCode.RETURN) + { + await WaitNavigationItemShowsUpAsync(cancellationToken); + } + + key.Apply(inputSimulator); + } + + await TestServices.JoinableTaskFactory.RunAsync(async () => + { + await WaitForApplicationIdleAsync(cancellationToken); + }); + } + + private async Task WaitNavigationItemShowsUpAsync(CancellationToken cancellationToken) + { + // Wait for the NavigateTo Features completes on Roslyn side. + await TestServices.Workspace.WaitForAllAsyncOperationsAsync(new[] { "NavigateTo" }, cancellationToken); + // Since the all-in-one search experience populates its results asychronously we need + // to give it time to update the UI. Note: This is not a perfect solution. + await Task.Delay(1000); + await WaitForApplicationIdleAsync(cancellationToken); + } +} diff --git a/vsintegration/tests/FSharp.Editor.IntegrationTests/InProcess/InputKey.cs b/vsintegration/tests/FSharp.Editor.IntegrationTests/InProcess/InputKey.cs new file mode 100644 index 00000000000..969d85470bc --- /dev/null +++ b/vsintegration/tests/FSharp.Editor.IntegrationTests/InProcess/InputKey.cs @@ -0,0 +1,111 @@ +// 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 System.Collections.Immutable; +using WindowsInput; +using WindowsInput.Native; + +namespace Microsoft.VisualStudio.Extensibility.Testing; + +internal readonly struct InputKey +{ + public readonly ImmutableArray Modifiers; + public readonly VirtualKeyCode VirtualKeyCode; + public readonly char? Character; + public readonly string? Text; + + public InputKey(VirtualKeyCode virtualKeyCode, ImmutableArray modifiers) + { + Modifiers = modifiers; + VirtualKeyCode = virtualKeyCode; + Character = null; + Text = null; + } + + public InputKey(char character) + { + Modifiers = ImmutableArray.Empty; + VirtualKeyCode = 0; + Character = character; + Text = null; + } + + public InputKey(string text) + { + Modifiers = ImmutableArray.Empty; + VirtualKeyCode = 0; + Character = null; + Text = text; + } + + public static implicit operator InputKey(char character) + => new(character); + + public static implicit operator InputKey(string text) + => new(text); + + public static implicit operator InputKey(VirtualKeyCode virtualKeyCode) + => new(virtualKeyCode, ImmutableArray.Empty); + + public static implicit operator InputKey((VirtualKeyCode virtualKeyCode, VirtualKeyCode modifier) modifiedKey) + => new(modifiedKey.virtualKeyCode, ImmutableArray.Create(modifiedKey.modifier)); + + public void Apply(IInputSimulator simulator) + { + if (Character is { } c) + { + if (c == '\n') + simulator.Keyboard.KeyPress(VirtualKeyCode.RETURN); + else if (c == '\t') + simulator.Keyboard.KeyPress(VirtualKeyCode.TAB); + else + simulator.Keyboard.TextEntry(c); + + return; + } + else if (Text is not null) + { + var offset = 0; + while (offset < Text.Length) + { + if (Text[offset] == '\r' && offset < Text.Length - 1 && Text[offset + 1] == '\n') + { + // Treat \r\n as a single RETURN character + offset++; + continue; + } + else if (Text[offset] == '\n') + { + simulator.Keyboard.KeyPress(VirtualKeyCode.RETURN); + offset++; + continue; + } + else if (Text[offset] == '\t') + { + simulator.Keyboard.KeyPress(VirtualKeyCode.TAB); + offset++; + continue; + } + else + { + var nextSpecial = Text.IndexOfAny(new[] { '\r', '\n', '\t' }, offset); + var endOfCurrentSegment = nextSpecial < 0 ? Text.Length : nextSpecial; + simulator.Keyboard.TextEntry(Text.Substring(offset, endOfCurrentSegment - offset)); + offset = endOfCurrentSegment; + } + } + + return; + } + + if (Modifiers.IsEmpty) + { + simulator.Keyboard.KeyPress(VirtualKeyCode); + } + else + { + simulator.Keyboard.ModifiedKeyStroke(Modifiers, VirtualKeyCode); + } + } +} diff --git a/vsintegration/tests/FSharp.Editor.IntegrationTests/InProcess/ShellInProcess.cs b/vsintegration/tests/FSharp.Editor.IntegrationTests/InProcess/ShellInProcess.cs new file mode 100644 index 00000000000..ff1cfe87ab8 --- /dev/null +++ b/vsintegration/tests/FSharp.Editor.IntegrationTests/InProcess/ShellInProcess.cs @@ -0,0 +1,85 @@ +// 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 System.Linq; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using System.Windows.Controls; +using System.Windows.Input; +using FSharp.Editor.IntegrationTests; +using Microsoft.VisualStudio.Shell.Interop; +using Microsoft.VisualStudio.Threading; +using Xunit; + +namespace Microsoft.VisualStudio.Extensibility.Testing; + +internal partial class ShellInProcess +{ + public async Task ShowNavigateToDialogAsync(CancellationToken cancellationToken) + { + await JoinableTaskFactory.SwitchToMainThreadAsync(cancellationToken); + + await TestServices.Shell.ExecuteCommandAsync(VSConstants.VSStd12CmdID.NavigateTo, cancellationToken); + + await WaitForNavigateToFocusAsync(cancellationToken); + + async Task WaitForNavigateToFocusAsync(CancellationToken cancellationToken) + { + bool isSearchActive = false; + + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Take no direct action regarding activation, but assert the correct item already has focus + await TestServices.JoinableTaskFactory.RunAsync(async () => + { + await TestServices.JoinableTaskFactory.SwitchToMainThreadAsync(); + var searchBox = Assert.IsAssignableFrom(Keyboard.FocusedElement); + if ("PART_SearchBox" == searchBox.Name || "SearchBoxControl" == searchBox.Name) + { + isSearchActive = true; + } + }); + + if (isSearchActive) + { + return; + } + + // If the dialog has not been displayed, then wait some time for it to show. The + // cancellation token passed in should be hang mitigating to avoid possible + // infinite loop. + await Task.Delay(100); + } + } + } + + // This is based on WaitForQuiescenceAsync in the FileChangeService tests + public async Task WaitForFileChangeNotificationsAsync(CancellationToken cancellationToken) + { + await JoinableTaskFactory.SwitchToMainThreadAsync(cancellationToken); + + var fileChangeService = await GetRequiredGlobalServiceAsync(cancellationToken); + Assumes.Present(fileChangeService); + + var jobSynchronizer = fileChangeService.GetPropertyValue("JobSynchronizer"); + Assumes.Present(jobSynchronizer); + + var type = jobSynchronizer.GetType(); + var methodInfo = type.GetMethod("GetActiveSpawnedTasks", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); + Assumes.Present(methodInfo); + + while (true) + { + var tasks = (Task[])methodInfo.Invoke(jobSynchronizer, null); + if (!tasks.Any()) + return; + + await Task.WhenAll(tasks); + } + } + +} \ No newline at end of file diff --git a/vsintegration/tests/FSharp.Editor.IntegrationTests/InProcess/SolutionExplorerInProcess.cs b/vsintegration/tests/FSharp.Editor.IntegrationTests/InProcess/SolutionExplorerInProcess.cs index 40835e07185..3e4e5d30191 100644 --- a/vsintegration/tests/FSharp.Editor.IntegrationTests/InProcess/SolutionExplorerInProcess.cs +++ b/vsintegration/tests/FSharp.Editor.IntegrationTests/InProcess/SolutionExplorerInProcess.cs @@ -66,6 +66,34 @@ public async Task AddProjectAsync(string projectName, string projectTemplate, Ca ErrorHandler.ThrowOnFailure(solution.AddNewProjectFromTemplate(projectTemplatePath, null, null, projectPath, projectName, null, out _)); } + public async Task AddFileAsync(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); + var directoryPath = Path.GetDirectoryName(filePath); + Directory.CreateDirectory(directoryPath); + + File.WriteAllText(filePath, contents); + _ = project.ProjectItems.AddFromFile(filePath); + } + + public async Task OpenFileAsync(string projectName, string relativeFilePath, CancellationToken cancellationToken) + { + await JoinableTaskFactory.SwitchToMainThreadAsync(cancellationToken); + + var filePath = await GetAbsolutePathForProjectRelativeFilePathAsync(projectName, relativeFilePath, cancellationToken); + VsShellUtilities.OpenDocument(ServiceProvider.GlobalProvider, filePath, VSConstants.LOGVIEWID.Code_guid, out _, out _, out _, out var view); + + // Reliably set focus using NavigateToLineAndColumn + var textManager = await GetRequiredGlobalServiceAsync(cancellationToken); + ErrorHandler.ThrowOnFailure(view.GetBuffer(out var textLines)); + ErrorHandler.ThrowOnFailure(view.GetCaretPos(out var line, out var column)); + ErrorHandler.ThrowOnFailure(textManager.NavigateToLineAndColumn(textLines, VSConstants.LOGVIEWID.Code_guid, line, column, line, column)); + } + private async Task GetProjectTemplatePathAsync(string projectTemplate, CancellationToken cancellationToken) { await JoinableTaskFactory.SwitchToMainThreadAsync(cancellationToken); @@ -76,6 +104,19 @@ private async Task GetProjectTemplatePathAsync(string projectTemplate, C return solution.GetProjectTemplate(projectTemplate, "FSharp"); } + private async Task GetAbsolutePathForProjectRelativeFilePathAsync(string projectName, string relativeFilePath, CancellationToken cancellationToken) + { + await JoinableTaskFactory.SwitchToMainThreadAsync(cancellationToken); + + var dte = await GetRequiredGlobalServiceAsync(cancellationToken); + var solution = dte.Solution; + Assumes.Present(solution); + + var project = solution.Projects.Cast().First(x => x.Name == projectName); + var projectPath = Path.GetDirectoryName(project.FullName); + return Path.Combine(projectPath, relativeFilePath); + } + public async Task RestoreNuGetPackagesAsync(CancellationToken cancellationToken) { await JoinableTaskFactory.SwitchToMainThreadAsync(cancellationToken); diff --git a/vsintegration/tests/FSharp.Editor.IntegrationTests/InProcess/WorkaroundsInProcess.cs b/vsintegration/tests/FSharp.Editor.IntegrationTests/InProcess/WorkaroundsInProcess.cs new file mode 100644 index 00000000000..7bda06a5a3b --- /dev/null +++ b/vsintegration/tests/FSharp.Editor.IntegrationTests/InProcess/WorkaroundsInProcess.cs @@ -0,0 +1,25 @@ +// 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 System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis.Shared.TestHooks; +using Microsoft.VisualStudio.Extensibility.Testing; + +namespace Microsoft.VisualStudio.Extensibility.Testing; + +[TestService] +internal partial class WorkaroundsInProcess +{ + public async Task WaitForNavigationAsync(CancellationToken cancellationToken) + { + await TestServices.Workspace.WaitForAllAsyncOperationsAsync(new[] { "Workspace", "NavigateTo" }, cancellationToken); + await TestServices.Editor.WaitForEditorOperationsAsync(cancellationToken); + + // It's not clear why this delay is necessary. Navigation operations are expected to fully complete as part + // of one of the above waiters, but GetActiveWindowCaptionAsync appears to return the previous window + // caption for a short delay after the above complete. + await Task.Delay(2000); + } +} \ No newline at end of file diff --git a/vsintegration/tests/FSharp.Editor.IntegrationTests/InProcess/WorkspaceInProcess.cs b/vsintegration/tests/FSharp.Editor.IntegrationTests/InProcess/WorkspaceInProcess.cs new file mode 100644 index 00000000000..d9d35dc32e5 --- /dev/null +++ b/vsintegration/tests/FSharp.Editor.IntegrationTests/InProcess/WorkspaceInProcess.cs @@ -0,0 +1,49 @@ +// 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 System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Build.Execution; +using Microsoft.CodeAnalysis.Editor.Shared.Utilities; +using Microsoft.CodeAnalysis.Host; +using Microsoft.CodeAnalysis.NavigateTo; +using Microsoft.CodeAnalysis.Shared.TestHooks; +using Microsoft.VisualStudio.LanguageServices; +using Microsoft.VisualStudio.Threading; + +namespace Microsoft.VisualStudio.Extensibility.Testing; + +internal partial class WorkspaceInProcess +{ + public async Task WaitForAllAsyncOperationsAsync(string[] featureNames, CancellationToken cancellationToken) + { + if (featureNames.Contains("Workspace")) + { + await WaitForProjectSystemAsync(cancellationToken); + await TestServices.Shell.WaitForFileChangeNotificationsAsync(cancellationToken); + await TestServices.Editor.WaitForEditorOperationsAsync(cancellationToken); + } + + var listenerProvider = await GetComponentModelServiceAsync(cancellationToken); + var workspace = await GetComponentModelServiceAsync(cancellationToken); + + if (featureNames.Contains("NavigateTo")) + { + var statusService = workspace.Services.GetRequiredService(); + + // Make sure the "priming" operation has started for Nav To + var threadingContext = await GetComponentModelServiceAsync(cancellationToken); + var asyncListener = listenerProvider.GetListener(FeatureAttribute.NavigateTo); + var searchHost = new DefaultNavigateToSearchHost(workspace.CurrentSolution, asyncListener, threadingContext.DisposalToken); + + // Calling DefaultNavigateToSearchHost.IsFullyLoadedAsync starts the fire-and-forget asynchronous + // operation to populate the remote host. The call to WaitAllAsync below will wait for that operation to + // complete. + await searchHost.IsFullyLoadedAsync(cancellationToken); + } + + await listenerProvider.WaitAllAsync(workspace, featureNames).WithCancellation(cancellationToken); + } +} diff --git a/vsintegration/tests/FSharp.Editor.IntegrationTests/NavigationTests.cs b/vsintegration/tests/FSharp.Editor.IntegrationTests/NavigationTests.cs new file mode 100644 index 00000000000..e144bc5270a --- /dev/null +++ b/vsintegration/tests/FSharp.Editor.IntegrationTests/NavigationTests.cs @@ -0,0 +1,55 @@ +// 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 Microsoft.VisualStudio.Extensibility.Testing; +using System.Threading.Tasks; +using WindowsInput.Native; +using Xunit; + +namespace FSharp.Editor.IntegrationTests; + +public class NavigationTests : AbstractIntegrationTest +{ + [IdeFact] + public async Task Navigate_Async() + { + var token = HangMitigatingCancellationToken; + var template = WellKnownProjectTemplates.FSharpNetCoreClassLibrary; + var solutionExplorer = TestServices.SolutionExplorer; + var shell = TestServices.Shell; + var editor = TestServices.Editor; + var input = TestServices.Input; + var workarounds = TestServices.Workarounds; + var projectName = "Library"; + var code1 = """ +namespace Library + +module Math1 = + let add x y = x + y + +"""; + var code2 = """ +namespace Library + +module Math2 = + let subtract x y = x - y + +"""; + + await solutionExplorer.CreateSolutionAsync(nameof(CreateProjectTests), token); + await solutionExplorer.AddProjectAsync(projectName, template, token); + await solutionExplorer.RestoreNuGetPackagesAsync(token); + await solutionExplorer.AddFileAsync(projectName, "Math1.fs", code1, token); + await solutionExplorer.AddFileAsync(projectName, "Math2.fs", code2, token); + await solutionExplorer.OpenFileAsync(projectName, "Math1.fs", token); + + await shell.ShowNavigateToDialogAsync(token); + await input.SendToNavigateToAsync(new InputKey[] { "subtract", VirtualKeyCode.RETURN }, token); + + await workarounds.WaitForNavigationAsync(token); + Assert.Equal("Math2.fs", await shell.GetActiveWindowCaptionAsync(token)); + Assert.Equal("subtract", await editor.GetSelectedTextAsync(token)); + } +} diff --git a/vsintegration/tests/FSharp.Editor.IntegrationTests/ObjectExtensions.cs b/vsintegration/tests/FSharp.Editor.IntegrationTests/ObjectExtensions.cs new file mode 100644 index 00000000000..1e3cc6c5908 --- /dev/null +++ b/vsintegration/tests/FSharp.Editor.IntegrationTests/ObjectExtensions.cs @@ -0,0 +1,60 @@ +// 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. + +#nullable disable + +using System; +using System.Reflection; + +namespace FSharp.Editor.IntegrationTests; + +public static class ObjectExtensions +{ + public static PropertyType GetPropertyValue(this object instance, string propertyName) + { + return (PropertyType)instance.GetPropertyValue(propertyName); + } + + public static object GetPropertyValue(this object instance, string propertyName) + { + var type = instance.GetType(); + var propertyInfo = type.GetProperty(propertyName, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); + if (propertyInfo == null) + { + throw new ArgumentException("Property " + propertyName + " was not found on type " + type.ToString()); + } + + var result = propertyInfo.GetValue(instance, null); + return result; + } + + public static object GetFieldValue(this object instance, string fieldName) + { + var type = instance.GetType(); + FieldInfo fieldInfo = null; + while (type != null) + { + fieldInfo = type.GetField(fieldName, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); + if (fieldInfo != null) + { + break; + } + + type = type.BaseType; + } + + if (fieldInfo == null) + { + throw new FieldAccessException("Field " + fieldName + " was not found on type " + type.ToString()); + } + + var result = fieldInfo.GetValue(instance); + return result; // you can place a breakpoint here (for debugging purposes) + } + + public static FieldType GetFieldValue(this object instance, string fieldName) + { + return (FieldType)instance.GetFieldValue(fieldName); + } +}