File: CompletionIntegrationTests.cs
Web Access
Project: src\src\Razor\src\Razor\test\Microsoft.VisualStudio.Razor.IntegrationTests\Microsoft.VisualStudio.Razor.IntegrationTests.csproj (Microsoft.VisualStudio.Razor.IntegrationTests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System;
using System.Diagnostics;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.VisualStudio.Extensibility.Testing;
using Roslyn.Test.Utilities;
using Xunit;
using Xunit.Abstractions;
 
namespace Microsoft.VisualStudio.Razor.IntegrationTests;
 
public class CompletionIntegrationTests(ITestOutputHelper testOutputHelper) : AbstractRazorEditorTest(testOutputHelper)
{
    private static readonly TimeSpan s_snippetTimeout = TimeSpan.FromSeconds(10);
 
    [IdeFact(Skip = "We're returning the right completion item, but the editor isn't applying it?")]
    public async Task SnippetCompletion_Html()
    {
        await VerifyTypeAndCommitCompletionAsync(
            input: """
                @page "/Test"
 
                <PageTitle>Test</PageTitle>
 
                <h1>Test</h1>
 
                @code {
                    private int currentCount = 0;
 
                    private void IncrementCount()
                    {
                        currentCount++;
                    }
                }
                """,
            output: """
                @page "/Test"
 
                <PageTitle>Test</PageTitle>
 
                <h1>Test</h1>
                <dl>
                    <dt></dt>
                    <dd></dd>
                </dl>
 
                @code {
                    private int currentCount = 0;
 
                    private void IncrementCount()
                    {
                        currentCount++;
                    }
                }
                """,
            search: "<h1>Test</h1>",
            expectedSelectedItemLabel: "dd",
            stringsToType: ["{ENTER}", "d", "d"]);
    }
 
    [IdeFact, WorkItem("https://github.com/dotnet/razor/issues/10787")]
    public async Task CompletionCommit_HtmlAttributeWithoutValue()
    {
        await VerifyTypeAndCommitCompletionAsync(
            input: """
                @page "/Test"
 
                <PageTitle>Test</PageTitle>
 
                <button></button>
 
                @code {
                    private int currentCount = 0;
 
                    private void IncrementCount()
                    {
                        currentCount++;
                    }
                }
                """,
            output: """
                @page "/Test"
 
                <PageTitle>Test</PageTitle>
 
                <button disabled></button>
 
                @code {
                    private int currentCount = 0;
 
                    private void IncrementCount()
                    {
                        currentCount++;
                    }
                }
                """,
            search: "<button",
            stringsToType: [" ", "d", "i", "s"],
            expectedSelectedItemLabel: "disabled");
    }
 
    [IdeFact]
    public async Task CompletionCommit_HtmlAttributeWithValue()
    {
        await VerifyTypeAndCommitCompletionAsync(
            input: """
                @page "/Test"
 
                <PageTitle>Test</PageTitle>
 
                <button></button>
 
                @code {
                    private int currentCount = 0;
 
                    private void IncrementCount()
                    {
                        currentCount++;
                    }
                }
                """,
            output: """
                @page "/Test"
 
                <PageTitle>Test</PageTitle>
 
                <button style=""></button>
 
                @code {
                    private int currentCount = 0;
 
                    private void IncrementCount()
                    {
                        currentCount++;
                    }
                }
                """,
            search: "<button",
            stringsToType: [" ", "s", "t", "y"],
            expectedSelectedItemLabel: "style");
    }
 
    [IdeFact]
    public async Task CompletionCommit_BlazorDirectiveAttribute()
    {
        await VerifyTypeAndCommitCompletionAsync(
            input: """
                @page "/test"
 
                <PageTitle>Test</PageTitle>
 
                <select @=""></select>
                """,
            output: """
                @page "/test"
                
                <PageTitle>Test</PageTitle>
 
                <select @onactivate =""></select>
                """,
            search: "<select @",
            stringsToType: ["o", "n", "a", "c"],
            commitChar: '\t',
            expectedSelectedItemLabel: "@onactivate");
    }
 
    // Regression test for https://devdiv.visualstudio.com/DevDiv/_workitems/edit/2505611
    [IdeFact]
    public async Task CompletionCommit_NoCommitOnTypingInDocComments()
    {
        await TestServices.SolutionExplorer.AddFileAsync(
            RazorProjectConstants.BlazorProjectName,
            "Test.razor",
            """
            @page "/test"
            
            <PageTitle>Test</PageTitle>
            
            @{
                /// 
            }
            """,
            open: true,
            ControlledHangMitigatingCancellationToken);
 
        var textView = await TestServices.Editor.GetActiveTextViewAsync(HangMitigatingCancellationToken);
        await TestServices.Editor.WaitForComponentClassificationAsync(ControlledHangMitigatingCancellationToken);
        await TestServices.Editor.DismissCompletionSessionsAsync(ControlledHangMitigatingCancellationToken);
 
        await TestServices.Editor.PlaceCaretAsync("/// ", charsOffset: 1, ControlledHangMitigatingCancellationToken);
        TestServices.Input.Send("add a function");
 
        // Make sure extra text didn't get commited from an unexpected completion list
        var currentLineText = await TestServices.Editor.GetCurrentLineTextAsync(HangMitigatingCancellationToken);
        Assert.Contains("/// add a function", currentLineText);
 
        // Make sure completion doesn't come up for 15 seconds
        var completionSession = await TestServices.Editor.WaitForExistingCompletionSessionAsync(s_snippetTimeout, HangMitigatingCancellationToken);
        var items = completionSession?.GetComputedItems(HangMitigatingCancellationToken);
 
        if (items is null)
        {
            // No items to check, we're good
            return;
        }
 
        // If completion did pop up with something like "Processing", make sure no doccomment items are present
        Assert.DoesNotContain("summary", items.Items.Select(i => i.DisplayText));
 
    }
 
    [IdeFact]
    public async Task CompletionCommit_HtmlTag()
    {
        await VerifyTypeAndCommitCompletionAsync(
            input: """
                @page "/Test"
 
                <PageTitle>Test</PageTitle>
 
                @code {
                    private int currentCount = 0;
 
                    private void IncrementCount()
                    {
                        currentCount++;
                    }
                }
                """,
            output: """
                @page "/Test"
 
                <PageTitle>Test</PageTitle>
 
                <span
 
                @code {
                    private int currentCount = 0;
 
                    private void IncrementCount()
                    {
                        currentCount++;
                    }
                }
                """,
            search: "</PageTitle>",
            stringsToType: ["{ENTER}", "{ENTER}", "<", "s", "p", "a"],
            expectedSelectedItemLabel: "span");
    }
 
    [IdeFact]
    public async Task CompletionCommit_WithAngleBracket_HtmlTag()
    {
        await VerifyTypeAndCommitCompletionAsync(
            input: """
                @page "/Test"
 
                <PageTitle>Test</PageTitle>
 
                @code {
                    private int currentCount = 0;
 
                    private void IncrementCount()
                    {
                        currentCount++;
                    }
                }
                """,
            output: """
                @page "/Test"
 
                <PageTitle>Test</PageTitle>
 
                <span></span>
 
                @code {
                    private int currentCount = 0;
 
                    private void IncrementCount()
                    {
                        currentCount++;
                    }
                }
                """,
            search: "</PageTitle>",
            stringsToType: ["{ENTER}", "{ENTER}", "<", "s", "p", "a"],
            commitChar: '>',
            "span");
    }
 
    [IdeFact]
    public async Task CompletionCommit_CSharp()
    {
        await VerifyTypeAndCommitCompletionAsync(
            input: """
                @page "/Test"
 
                <PageTitle>Test</PageTitle>
 
                @code {
                    private int myCurrentCount = 0;
 
                    private void IncrementCount()
                    {
                        myCurrentCount++;
                    }
                }
                """,
            output: """
                @page "/Test"
 
                <PageTitle>Test</PageTitle>
 
                @code {
                    private int myCurrentCount = 0;
 
                    private void IncrementCount()
                    {
                        myCurrentCount++;
 
                        myCurrentCount
                    }
                }
                """,
            search: "myCurrentCount++;",
            stringsToType: ["{ENTER}", "{ENTER}", "m", "y", "C", "u", "r"],
            expectedSelectedItemLabel: "myCurrentCount");
    }
 
    [IdeFact]
    public async Task CompletionCommit_CSharp_Override()
    {
        await VerifyTypeAndCommitCompletionAsync(
            input: """
                @page "/Test"
 
                <PageTitle>Test</PageTitle>
 
                @code {
                    private int myCurrentCount = 0;
 
                    override
                }
                """,
            output: """
                @page "/Test"
 
                <PageTitle>Test</PageTitle>
 
                @code {
                    private int myCurrentCount = 0;
 
                    protected override void OnAfterRender(bool firstRender)
                    {
                        base.OnAfterRender(firstRender);
                    }
                }
                """,
            search: "override",
            stringsToType: [" ", "O", "n", "A"],
            commitChar: '\t');
    }
 
    [IdeFact]
    public async Task SnippetCompletion_DoesntCommitOnSpace()
    {
        await TestServices.SolutionExplorer.AddFileAsync(
            RazorProjectConstants.BlazorProjectName,
            "Test.razor",
            """
            @page "/Test"
 
            <PageTitle>Test</PageTitle>
 
            <div></div>
            """,
            open: true,
            ControlledHangMitigatingCancellationToken);
 
        var textView = await TestServices.Editor.GetActiveTextViewAsync(HangMitigatingCancellationToken);
        await TestServices.Editor.WaitForComponentClassificationAsync(ControlledHangMitigatingCancellationToken);
 
        await TestServices.Editor.PlaceCaretAsync("<div></div>", charsOffset: 1, ControlledHangMitigatingCancellationToken);
        TestServices.Input.Send("{ENTER}");
        TestServices.Input.Send("i");
        TestServices.Input.Send("f");
        TestServices.Input.Send("r");
 
        // Wait until completion comes up before validating
        // that space does not commit
        await TestServices.Editor.WaitForCompletionSessionAsync(HangMitigatingCancellationToken);
 
        TestServices.Input.Send(" ");
 
        var text = textView.TextBuffer.CurrentSnapshot.GetText();
 
        var expected = """
            @page "/Test"
            
            <PageTitle>Test</PageTitle>
            
            <div></div>
            ifr
            """;
 
        AssertEx.EqualOrDiff(expected, text);
    }
 
    [IdeFact]
    [WorkItem("https://github.com/dotnet/razor/issues/9427")]
    public async Task Snippets_DoNotTrigger_OnDelete()
    {
        await TestServices.SolutionExplorer.AddFileAsync(
            RazorProjectConstants.BlazorProjectName,
            "Test.razor",
            """
            @page "/Test"
 
            <PageTitle>Test</PageTitle>
 
            <div>Hello</div>
            """,
            open: true,
            ControlledHangMitigatingCancellationToken);
 
        await TestServices.Editor.WaitForComponentClassificationAsync(ControlledHangMitigatingCancellationToken);
        await TestServices.Editor.DismissCompletionSessionsAsync(ControlledHangMitigatingCancellationToken);
 
        await TestServices.Editor.PlaceCaretAsync("Hel", charsOffset: 1, ControlledHangMitigatingCancellationToken);
        TestServices.Input.Send("{DELETE}");
 
        // Make sure completion doesn't come up for 15 seconds
        var completionSession = await TestServices.Editor.WaitForExistingCompletionSessionAsync(s_snippetTimeout, HangMitigatingCancellationToken);
        Assert.Null(completionSession);
    }
 
    [IdeTheory]
    [InlineData("<PageTitle")]
    [InlineData("</PageTitle")]
    [InlineData("<div")]
    [InlineData("</div")]
    [InlineData("// script block ")]
    [InlineData("/* style block ")]
    [InlineData("<!-- comment block ")]
    [WorkItem("https://github.com/dotnet/razor/issues/9427")]
    // Do not trigger snippets in start tags, end tags, script blocks, style blocks, or comments
    public async Task Snippets_DoNotTrigger_InDisallowedContext(string tag)
    {
        await TestServices.SolutionExplorer.AddFileAsync(
            RazorProjectConstants.BlazorProjectName,
            "Test.razor",
            """
            @page "/Test"
 
            <script>
                // script block 
            </script>
 
            <style>
                /* style block  */
            </style>
 
            <!-- comment block  -->
 
            <PageTitle>Test</PageTitle>
 
            <div></div>
            """,
            open: true,
            ControlledHangMitigatingCancellationToken);
 
        var textView = await TestServices.Editor.GetActiveTextViewAsync(HangMitigatingCancellationToken);
        await TestServices.Editor.WaitForComponentClassificationAsync(ControlledHangMitigatingCancellationToken);
        await TestServices.Editor.DismissCompletionSessionsAsync(ControlledHangMitigatingCancellationToken);
 
        await TestServices.Editor.PlaceCaretAsync(tag, charsOffset: 1, ControlledHangMitigatingCancellationToken);
        TestServices.Input.Send(" ");
        TestServices.Input.Send("dd");
 
        // Make sure completion doesn't come up for 15 seconds
        var completionSession = await TestServices.Editor.WaitForExistingCompletionSessionAsync(s_snippetTimeout, HangMitigatingCancellationToken);
        var items = completionSession?.GetComputedItems(HangMitigatingCancellationToken);
 
        if (items is null)
        {
            // No items to check, we're good
            return;
        }
 
        Assert.DoesNotContain("dd", items.Items.Select(i => i.DisplayText));
    }
 
    [IdeFact, WorkItem("https://github.com/dotnet/razor/issues/9346")]
    public async Task Completion_EnumDot()
    {
        await TestServices.SolutionExplorer.AddFileAsync(
            RazorProjectConstants.BlazorProjectName,
            "Test.razor",
            """
            <Test Param="@MyEnum." />
 
            @code {
                [Parameter] public string Param { get; set; }
 
                public enum MyEnum
                {
                    One
                }
            }
            """,
            open: true,
            ControlledHangMitigatingCancellationToken);
 
        await TestServices.Editor.WaitForComponentClassificationAsync(ControlledHangMitigatingCancellationToken);
 
        await TestServices.Editor.PlaceCaretAsync("@MyEnum.", charsOffset: 1, ControlledHangMitigatingCancellationToken);
 
        await Task.Delay(500, HangMitigatingCancellationToken);
 
        TestServices.Input.Send("O");
 
        await CommitCompletionAndVerifyAsync("""
            <Test Param="@MyEnum.One" />
            
            @code {
                [Parameter] public string Param { get; set; }
            
                public enum MyEnum
                {
                    One
                }
            }
            """);
    }
 
    [IdeFact, WorkItem("https://github.com/dotnet/razor/issues/11385")]
    public async Task ProvisionalCompletion_DoesntBreakSemanticTokens()
    {
        await TestServices.SolutionExplorer.AddFileAsync(
            RazorProjectConstants.BlazorProjectName,
            "Test.razor",
            """
            @page "/counter"
 
            <PageTitle>Counter</PageTitle>
 
            <h1>Counter</h1>
 
            <p role="status">Current count: @currentCount</p>
 
            @DateTime
 
            <button class="btn btn-primary" @onclick="IncrementCount">Click me</button>
 
            @code {
                private int currentCount = 0;
 
                public void IncrementCount()
                {
                    currentCount++;
                }
            }
            """,
            open: true,
            ControlledHangMitigatingCancellationToken);
 
        await TestServices.Editor.WaitForComponentClassificationAsync(ControlledHangMitigatingCancellationToken);
 
        await TestServices.Editor.PlaceCaretAsync("@DateTime", charsOffset: 1, ControlledHangMitigatingCancellationToken);
 
        await Task.Delay(500, HangMitigatingCancellationToken);
 
        TestServices.Input.Send(".");
        TestServices.Input.Send("n");
 
        await Task.Delay(500, HangMitigatingCancellationToken);
 
        await TestServices.Editor.ValidateNoDiscoColorsAsync(HangMitigatingCancellationToken);
    }
 
    [IdeFact]
    [WorkItem("https://github.com/dotnet/razor/issues/11565")]
    public async Task TagHelpers_Present_OnBackspace()
    {
        await TestServices.SolutionExplorer.AddFileAsync(
            RazorProjectConstants.BlazorProjectName,
            "Test.razor",
            """
            <PageTitle>Test</PageTitle>
            """,
            open: true,
            ControlledHangMitigatingCancellationToken);
 
        await TestServices.Editor.WaitForComponentClassificationAsync(ControlledHangMitigatingCancellationToken);
 
        await TestServices.Editor.PlaceCaretAsync("<PageTitle", charsOffset: 1, ControlledHangMitigatingCancellationToken);
        TestServices.Input.Send("{BACKSPACE}");
 
        var completionSession = await TestServices.Editor.WaitForCompletionSessionAsync(s_snippetTimeout, HangMitigatingCancellationToken);
        var items = completionSession?.GetComputedItems(HangMitigatingCancellationToken);
 
        Assert.NotNull(items);
        Assert.Contains("PageTitle", items.Items.Select(i => i.DisplayText));
    }
 
    private async Task VerifyTypeAndCommitCompletionAsync(string input, string output, string search, string[] stringsToType, char? commitChar = null, string? expectedSelectedItemLabel = null)
    {
        const string CompletionTestFileName = "Completion.razor";
 
        await TestServices.SolutionExplorer.AddFileAsync(
            RazorProjectConstants.BlazorProjectName,
            CompletionTestFileName,
            input,
            open: true,
            ControlledHangMitigatingCancellationToken);
 
        await TestServices.Editor.WaitForComponentClassificationAsync(ControlledHangMitigatingCancellationToken);
 
        var filePath = await TestServices.SolutionExplorer.GetAbsolutePathForProjectRelativeFilePathAsync(RazorProjectConstants.BlazorProjectName, CompletionTestFileName, ControlledHangMitigatingCancellationToken);
 
        await TestServices.Editor.PlaceCaretAsync(search, charsOffset: 1, ControlledHangMitigatingCancellationToken);
        foreach (var stringToType in stringsToType)
        {
            await TestServices.RazorProjectSystem.WaitForHtmlVirtualDocumentUpdateAsync(RazorProjectConstants.BlazorProjectName, filePath, () =>
            {
                TestServices.Input.Send(stringToType);
 
                return Task.CompletedTask;
            }, ControlledHangMitigatingCancellationToken);
        }
 
        if (expectedSelectedItemLabel is not null)
        {
            await CommitCompletionAndVerifyAsync(output, expectedSelectedItemLabel, commitChar);
        }
        else
        {
            await CommitCompletionAndVerifyAsync(output, commitChar);
        }
    }
 
    private async Task CommitCompletionAndVerifyAsync(string expected, char? commitChar = null)
    {
        var session = await TestServices.Editor.WaitForCompletionSessionAsync(HangMitigatingCancellationToken);
 
        if (session is null)
        {
            Assert.Fail(await TestServices.Editor.GetCompletionSessionDebugInfoAsync(expectedSelectedItemLabel: null, HangMitigatingCancellationToken));
        }
 
        var completionSession = session ?? throw new InvalidOperationException("Completion session should have been available.");
        if (commitChar.HasValue)
        {
            // Commit using the specified commit character
            completionSession.Commit(commitChar.Value, HangMitigatingCancellationToken);
 
            // session.Commit call above commits as if the commit character was typed,
            // but doesn't actually insert the character into the buffer.
            // So we still need to insert the character into the buffer ourselves.
            TestServices.Input.Send(commitChar.Value.ToString());
        }
        else
        {
            Assert.True(completionSession.CommitIfUnique(HangMitigatingCancellationToken));
        }
 
        var textView = await TestServices.Editor.GetActiveTextViewAsync(HangMitigatingCancellationToken);
        var text = textView.TextBuffer.CurrentSnapshot.GetText();
 
        // Snippets may have slight whitespace differences due to line endings. These
        // tests allow for it as long as the content is correct
        AssertEx.AssertEqualToleratingWhitespaceDifferences(expected, text);
    }
 
    private async Task CommitCompletionAndVerifyAsync(string expected, string expectedSelectedItemLabel, char? commitChar = null)
    {
        // Let outstanding async Intellisense work settle before we interrogate the active completion session.
        await TestServices.Shell.WaitForOperationProgressAsync(HangMitigatingCancellationToken);
 
        // Actually open completion UI and wait for it have selected item we are interested in
        var session = await TestServices.Editor.OpenCompletionSessionAndWaitForItemAsync(TimeSpan.FromSeconds(10), expectedSelectedItemLabel, HangMitigatingCancellationToken);
 
        if (session is null)
        {
            Assert.Fail(await TestServices.Editor.GetCompletionSessionDebugInfoAsync(expectedSelectedItemLabel, HangMitigatingCancellationToken));
        }
 
        var completionSession = session ?? throw new InvalidOperationException("Completion session should have been available.");
        if (commitChar is char commitCharValue)
        {
            // Commit using the specified commit character
            completionSession.Commit(commitCharValue, HangMitigatingCancellationToken);
 
            // session.Commit call above commits as if the commit character was typed,
            // but doesn't actually insert the character into the buffer.
            // So we still need to insert the character into the buffer ourselves.
            TestServices.Input.Send(commitCharValue.ToString());
        }
        else
        {
            Assert.True(completionSession.CommitIfUnique(HangMitigatingCancellationToken));
        }
 
        var textView = await TestServices.Editor.GetActiveTextViewAsync(HangMitigatingCancellationToken);
 
        var stopwatch = Stopwatch.StartNew();
        string text;
        while ((text = textView.TextBuffer.CurrentSnapshot.GetText()) != expected && stopwatch.ElapsedMilliseconds < EditorInProcess.DefaultCompletionWaitTimeMilliseconds)
        {
            // Text might get updated *after* completion by something like auto-insert, so wait for the desired text
            await Task.Delay(100, HangMitigatingCancellationToken);
        }
 
        // Snippets may have slight whitespace differences due to line endings. These
        // tests allow for it as long as the content is correct
        Assert.Equal(expected, text);
    }
}