Skip to content

Commit

Permalink
Add tests for the cohost linked editing range endpoint (#10596)
Browse files Browse the repository at this point in the history
Part of #9519

Finally some cohosting tests! Some people would say that I'm being lazy
in adding tests for the simplest endpoint we have. Those people have a
point.

The test infra here almost entirely avoids ServiceHub, and certainly
avoids the Roslyn solution sync mechanism, but it _does_ use our real
services and service factories, including the OOP services' separate MEF
composition, so the services themselves are partying on a real Roslyn
(test) solution and are using real implementations of all of their
dependencies.

Things not covered by this test infra yet:
* OOP initialization
* Adding generated C# files to the Roslyn solution (IDynamicFile does
this in real life)
* Dealing with Html documents in any way

Future PRs to add tests for more endpoints should add these as needed.
  • Loading branch information
davidwengier committed Jul 10, 2024
2 parents b24cd9c + a1d07e0 commit 6af89f3
Show file tree
Hide file tree
Showing 12 changed files with 445 additions and 6 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT license. See License.txt in the project root for license information.

using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis.ExternalAccess.Razor;
using Microsoft.CodeAnalysis.ExternalAccess.Razor.Api;

namespace Microsoft.CodeAnalysis.Remote.Razor;

/// <summary>
/// An abstraction to avoid calling the static <see cref="RazorBrokeredServiceImplementation"/> helper defined in Roslyn
/// </summary>
internal interface IBrokeredServiceInterceptor
{
ValueTask RunServiceAsync(Func<CancellationToken, ValueTask> implementation, CancellationToken cancellationToken);

ValueTask<T> RunServiceAsync<T>(RazorPinnedSolutionInfoWrapper solutionInfo, Func<Solution, ValueTask<T>> implementation, CancellationToken cancellationToken);
}
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
<InternalsVisibleTo Include="DynamicProxyGenAssembly2" Key="$(MoqPublicKey)" />
<InternalsVisibleTo Include="Microsoft.AspNetCore.Razor.Microbenchmarks" Key="$(RazorKey)" />
<InternalsVisibleTo Include="Microsoft.CodeAnalysis.Remote.Razor.Test" Key="$(RazorKey)" />
<InternalsVisibleTo Include="Microsoft.VisualStudio.LanguageServices.Razor.Test" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,19 @@ namespace Microsoft.CodeAnalysis.Remote.Razor;
internal abstract class RazorServiceBase : IDisposable
{
private readonly ServiceBrokerClient _serviceBrokerClient;
private readonly IBrokeredServiceInterceptor? _brokeredServiceInterceptor;

public RazorServiceBase(IServiceBroker serviceBroker)
{
_brokeredServiceInterceptor = serviceBroker as IBrokeredServiceInterceptor;
_serviceBrokerClient = new ServiceBrokerClient(serviceBroker, joinableTaskFactory: null);
}

protected ValueTask RunServiceAsync(Func<CancellationToken, ValueTask> implementation, CancellationToken cancellationToken)
=> RazorBrokeredServiceImplementation.RunServiceAsync(implementation, cancellationToken);
=> _brokeredServiceInterceptor?.RunServiceAsync(implementation, cancellationToken) ?? RazorBrokeredServiceImplementation.RunServiceAsync(implementation, cancellationToken);

protected ValueTask<T> RunServiceAsync<T>(RazorPinnedSolutionInfoWrapper solutionInfo, Func<Solution, ValueTask<T>> implementation, CancellationToken cancellationToken)
=> RazorBrokeredServiceImplementation.RunServiceAsync(solutionInfo, _serviceBrokerClient, implementation, cancellationToken);
=> _brokeredServiceInterceptor?.RunServiceAsync(solutionInfo, implementation, cancellationToken) ?? RazorBrokeredServiceImplementation.RunServiceAsync(solutionInfo, _serviceBrokerClient, implementation, cancellationToken);

public void Dispose()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Razor;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.ExternalAccess.Razor.Cohost;
using Microsoft.CodeAnalysis.Razor.LinkedEditingRange;
using Microsoft.CodeAnalysis.Razor.Logging;
Expand Down Expand Up @@ -53,10 +54,11 @@ internal class CohostLinkedEditingRangeEndpoint(IRemoteServiceProvider remoteSer
protected override RazorTextDocumentIdentifier? GetRazorTextDocumentIdentifier(LinkedEditingRangeParams request)
=> request.TextDocument.ToRazorTextDocumentIdentifier();

protected override async Task<LinkedEditingRanges?> HandleRequestAsync(LinkedEditingRangeParams request, RazorCohostRequestContext context, CancellationToken cancellationToken)
{
var razorDocument = context.TextDocument.AssumeNotNull();
protected override Task<LinkedEditingRanges?> HandleRequestAsync(LinkedEditingRangeParams request, RazorCohostRequestContext context, CancellationToken cancellationToken)
=> HandleRequestAsync(request, context.TextDocument.AssumeNotNull(), cancellationToken);

private async Task<LinkedEditingRanges?> HandleRequestAsync(LinkedEditingRangeParams request, TextDocument razorDocument, CancellationToken cancellationToken)
{
var linkedRanges = await _remoteServiceProvider.TryInvokeAsync<IRemoteLinkedEditingRangeService, LinePositionSpan[]?>(
razorDocument.Project.Solution,
(service, solutionInfo, cancellationToken) => service.GetRangesAsync(solutionInfo, razorDocument.Id, request.Position.ToLinePosition(), cancellationToken),
Expand All @@ -73,4 +75,12 @@ internal class CohostLinkedEditingRangeEndpoint(IRemoteServiceProvider remoteSer

return null;
}

internal TestAccessor GetTestAccessor() => new(this);

internal readonly struct TestAccessor(CohostLinkedEditingRangeEndpoint instance)
{
public Task<LinkedEditingRanges?> HandleRequestAsync(LinkedEditingRangeParams request, TextDocument razorDocument, CancellationToken cancellationToken)
=> instance.HandleRequestAsync(request, razorDocument, cancellationToken);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ internal sealed class RemoteServiceProvider(
catch (Exception ex) when (ex is not OperationCanceledException)
{
var approximateCallingClassName = Path.GetFileNameWithoutExtension(callerFilePath);
_logger.LogError(ex, $"Error calling remote method for {typeof(TService).Name} service, invocation: ${approximateCallingClassName}.{callerMemberName}");
_logger.LogError(ex, $"Error calling remote method for {typeof(TService).Name} service, invocation: {approximateCallingClassName}.{callerMemberName}");
_telemetryReporter.ReportFault(ex, "Exception calling remote method for {service}, invocation: {class}.{method}", typeof(TService).FullName, approximateCallingClassName, callerMemberName);
return default;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,12 @@ static ToolingTestBase()
/// </summary>
internal ILoggerFactory LoggerFactory { get; }

/// <summary>
/// An <see cref="ITestOutputHelper"/> that the currently running test can use to write
/// though using <see cref="LoggerFactory"/> is probably preferred.
/// </summary>
internal ITestOutputHelper TestOutputHelper { get; }

private ILogger? _logger;

/// <summary>
Expand All @@ -86,6 +92,7 @@ protected ToolingTestBase(ITestOutputHelper testOutput)
_disposalTokenSource = new();
DisposalToken = _disposalTokenSource.Token;

TestOutputHelper = testOutput;
LoggerFactory = new TestOutputLoggerFactory(testOutput);

// Give this thread a name, so it's easier to find in the VS Threads window.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT license. See License.txt in the project root for license information.

using System.Collections.Immutable;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis.ExternalAccess.Razor;
using Microsoft.CodeAnalysis.Razor.LinkedEditingRange;
using Microsoft.CodeAnalysis.Razor.Workspaces;
using Microsoft.CodeAnalysis.Testing;
using Microsoft.CodeAnalysis.Text;
using Microsoft.VisualStudio.LanguageServer.Protocol;
using Microsoft.VisualStudio.Razor.LanguageClient.Cohost;
using Xunit;
using Xunit.Abstractions;

namespace Microsoft.VisualStudio.LanguageServices.Razor.Test.Cohost;

public class CohostLinkedEditingRangeTest(ITestOutputHelper testOutputHelper) : CohostTestBase(testOutputHelper)
{
[Theory]
[InlineData("$$div")]
[InlineData("di$$v")]
[InlineData("div$$")]
public async Task Html_StartTag(string startTagAndCursorLocation)
{
var input = $"""
This is a Razor document.

<[|{startTagAndCursorLocation}|]>
Here is some content.
</[|div|]>

The end.
""";

await VerifyLinkedEditingRangeAsync(input);
}

[Theory]
[InlineData("$$div")]
[InlineData("di$$v")]
[InlineData("div$$")]
public async Task Html_EndTag(string endTagAndCursorLocation)
{
var input = $"""
This is a Razor document.

<[|div|]>
Here is some content.
</[|{endTagAndCursorLocation}|]>

The end.
""";

await VerifyLinkedEditingRangeAsync(input);
}

[Fact]
public async Task Html_EndTag_BeforeSlash()
{
var input = $"""
This is a Razor document.

<div>
Here is some content.
<$$/div>

The end.
""";

await VerifyLinkedEditingRangeAsync(input);
}

[Fact]
public async Task Html_NotATag()
{
var input = $"""
This is a $$Razor document.

<div>
Here is some content.
</div>

The end.
""";

await VerifyLinkedEditingRangeAsync(input);
}

[Fact]
public async Task Html_NestedTags_Outer()
{
var input = $"""
This is a Razor document.

<[|d$$iv|]>
<div>
Here is some content.
</div>
</[|div|]>

The end.
""";

await VerifyLinkedEditingRangeAsync(input);
}

[Fact]
public async Task Html_NestedTags_Inner()
{
var input = $"""
This is a Razor document.

<div>
<[|d$$iv|]>
Here is some content.
</[|div|]>
</div>

The end.
""";

await VerifyLinkedEditingRangeAsync(input);
}

[Fact]
public async Task Html_SelfClosingTag()
{
var input = $"""
This is a Razor document.

<b$$r />
Here is some content.

The end.
""";

await VerifyLinkedEditingRangeAsync(input);
}

private async Task VerifyLinkedEditingRangeAsync(string input)
{
TestFileMarkupParser.GetPositionAndSpans(input, out input, out int cursorPosition, out ImmutableArray<TextSpan> spans);
var document = CreateRazorDocument(input);
var sourceText = await document.GetTextAsync(DisposalToken);
sourceText.GetLineAndOffset(cursorPosition, out var lineIndex, out var characterIndex);

var endpoint = new CohostLinkedEditingRangeEndpoint(RemoteServiceProvider, LoggerFactory);

var request = new LinkedEditingRangeParams()
{
TextDocument = new TextDocumentIdentifier()
{
Uri = document.CreateUri()
},
Position = new Position()
{
Line = lineIndex,
Character = characterIndex
}
};

var result = await endpoint.GetTestAccessor().HandleRequestAsync(request, document, DisposalToken);

if (spans.Length == 0)
{
Assert.Null(result);
return;
}

Assert.NotNull(result);
Assert.Equal(LinkedEditingRangeHelper.WordPattern, result.WordPattern);
Assert.Equal(spans[0], result.Ranges[0].ToTextSpan(sourceText));
Assert.Equal(spans[1], result.Ranges[1].ToTextSpan(sourceText));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT license. See License.txt in the project root for license information.

using System.IO;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Razor;
using Microsoft.AspNetCore.Razor.Test.Common;
using Microsoft.AspNetCore.Razor.Test.Common.Workspaces;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Razor.Remote;
using Microsoft.CodeAnalysis.Text;
using Xunit.Abstractions;

namespace Microsoft.VisualStudio.LanguageServices.Razor.Test.Cohost;

public abstract class CohostTestBase(ITestOutputHelper testOutputHelper) : WorkspaceTestBase(testOutputHelper)
{
private IRemoteServiceProvider? _remoteServiceProvider;

private protected IRemoteServiceProvider RemoteServiceProvider => _remoteServiceProvider.AssumeNotNull();

protected override Task InitializeAsync()
{
_remoteServiceProvider = new ShortCircuitingRemoteServiceProvider(TestOutputHelper);

return base.InitializeAsync();
}

protected TextDocument CreateRazorDocument(string contents)
{
var projectFilePath = TestProjectData.SomeProject.FilePath;
var documentFilePath = TestProjectData.SomeProjectComponentFile1.FilePath;
var projectName = Path.GetFileNameWithoutExtension(projectFilePath);
var projectId = ProjectId.CreateNewId(debugName: projectName);
var documentId = DocumentId.CreateNewId(projectId, debugName: documentFilePath);

var solution = Workspace.CurrentSolution.AddProject(ProjectInfo.Create(
projectId,
VersionStamp.Create(),
name: projectName,
assemblyName: projectName,
LanguageNames.CSharp,
documentFilePath));

solution = solution.AddAdditionalDocument(
documentId,
documentFilePath,
SourceText.From(contents),
filePath: documentFilePath);

return solution.GetAdditionalDocument(documentId).AssumeNotNull();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT license. See License.txt in the project root for license information.

using System;
using System.IO.Pipelines;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.ExternalAccess.Razor;
using Microsoft.CodeAnalysis.Remote.Razor;
using Microsoft.ServiceHub.Framework;

namespace Microsoft.VisualStudio.LanguageServices.Razor.Test.Cohost;

internal class InterceptingServiceBroker(Solution solution) : IServiceBroker, IBrokeredServiceInterceptor
{
public event EventHandler<BrokeredServicesChangedEventArgs>? AvailabilityChanged { add { } remove { } }

public ValueTask<IDuplexPipe?> GetPipeAsync(ServiceMoniker serviceMoniker, ServiceActivationOptions options = default, CancellationToken cancellationToken = default)
{
throw new NotImplementedException();
}

public ValueTask<T?> GetProxyAsync<T>(ServiceRpcDescriptor serviceDescriptor, ServiceActivationOptions options = default, CancellationToken cancellationToken = default)
where T : class
{
throw new NotImplementedException();
}

public ValueTask RunServiceAsync(Func<CancellationToken, ValueTask> implementation, CancellationToken cancellationToken)
{
return implementation(cancellationToken);
}

public ValueTask<T> RunServiceAsync<T>(RazorPinnedSolutionInfoWrapper solutionInfo, Func<Solution, ValueTask<T>> implementation, CancellationToken cancellationToken)
{
return implementation(solution);
}
}
Loading

0 comments on commit 6af89f3

Please sign in to comment.