From 7f7752b955a98082b412595a3261b3aa7c4da6bf Mon Sep 17 00:00:00 2001 From: David Wengier Date: Tue, 18 Apr 2023 16:20:38 +1000 Subject: [PATCH] Create some simple tests for the RoslynWorkspace EA (and fix a glaring bug!) --- Razor.sln | 11 ++ .../PublicAPI.Unshipped.txt | 3 +- .../RazorWorkspaceListener.cs | 60 ++++++-- ...ExternalAccess.RoslynWorkspace.Test.csproj | 17 +++ .../RazorWorkspaceListenerTest.cs | 141 ++++++++++++++++++ .../xunit.runner.json | 5 + 6 files changed, 221 insertions(+), 16 deletions(-) create mode 100644 src/Razor/test/Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace.Test/Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace.Test.csproj create mode 100644 src/Razor/test/Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace.Test/RazorWorkspaceListenerTest.cs create mode 100644 src/Razor/test/Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace.Test/xunit.runner.json diff --git a/Razor.sln b/Razor.sln index 140f89c223d..cff1cd632c1 100644 --- a/Razor.sln +++ b/Razor.sln @@ -176,6 +176,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Razor. EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Razor.ProjectEngineHost", "src\Razor\src\Microsoft.AspNetCore.Razor.ProjectEngineHost\Microsoft.AspNetCore.Razor.ProjectEngineHost.csproj", "{2FB4801C-A083-4F08-A4FB-C4910985DE31}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace.Test", "src\Razor\test\Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace.Test\Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace.Test.csproj", "{70C6EAF1-202B-481B-ADD4-D30DF1396BDE}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -736,6 +738,14 @@ Global {2FB4801C-A083-4F08-A4FB-C4910985DE31}.Release|Any CPU.Build.0 = Release|Any CPU {2FB4801C-A083-4F08-A4FB-C4910985DE31}.ReleaseNoVSIX|Any CPU.ActiveCfg = Release|Any CPU {2FB4801C-A083-4F08-A4FB-C4910985DE31}.ReleaseNoVSIX|Any CPU.Build.0 = Release|Any CPU + {70C6EAF1-202B-481B-ADD4-D30DF1396BDE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {70C6EAF1-202B-481B-ADD4-D30DF1396BDE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {70C6EAF1-202B-481B-ADD4-D30DF1396BDE}.DebugNoVSIX|Any CPU.ActiveCfg = Debug|Any CPU + {70C6EAF1-202B-481B-ADD4-D30DF1396BDE}.DebugNoVSIX|Any CPU.Build.0 = Debug|Any CPU + {70C6EAF1-202B-481B-ADD4-D30DF1396BDE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {70C6EAF1-202B-481B-ADD4-D30DF1396BDE}.Release|Any CPU.Build.0 = Release|Any CPU + {70C6EAF1-202B-481B-ADD4-D30DF1396BDE}.ReleaseNoVSIX|Any CPU.ActiveCfg = Release|Any CPU + {70C6EAF1-202B-481B-ADD4-D30DF1396BDE}.ReleaseNoVSIX|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -817,6 +827,7 @@ Global {3E2B6DF5-524F-4909-8A66-7F8C6383620A} = {3C0D6505-79B3-49D0-B4C3-176F0F1836ED} {2223B8FD-D98A-47BE-94A9-6A3A6B8557B8} = {3C0D6505-79B3-49D0-B4C3-176F0F1836ED} {2FB4801C-A083-4F08-A4FB-C4910985DE31} = {3C0D6505-79B3-49D0-B4C3-176F0F1836ED} + {70C6EAF1-202B-481B-ADD4-D30DF1396BDE} = {92463391-81BE-462B-AC3C-78C6C760741F} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {0035341D-175A-4D05-95E6-F1C2785A1E26} diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace/PublicAPI.Unshipped.txt b/src/Razor/src/Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace/PublicAPI.Unshipped.txt index 87b2c0ddf60..3080a9d5908 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace/PublicAPI.Unshipped.txt +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace/PublicAPI.Unshipped.txt @@ -2,4 +2,5 @@ Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace.RazorWorkspaceListener Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace.RazorWorkspaceListener.Dispose() -> void Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace.RazorWorkspaceListener.EnsureInitialized(Microsoft.CodeAnalysis.Workspace! workspace, string! projectRazorJsonFileName) -> void -Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace.RazorWorkspaceListener.RazorWorkspaceListener() -> void \ No newline at end of file +Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace.RazorWorkspaceListener.RazorWorkspaceListener() -> void +virtual Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace.RazorWorkspaceListener.SerializeProjectAsync(Microsoft.CodeAnalysis.Project! project, System.Threading.CancellationToken ct) -> System.Threading.Tasks.Task! \ No newline at end of file diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace/RazorWorkspaceListener.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace/RazorWorkspaceListener.cs index 46f0631775d..33354a2b9cd 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace/RazorWorkspaceListener.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace/RazorWorkspaceListener.cs @@ -1,7 +1,6 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the MIT license. See License.txt in the project root for license information. -using System.Runtime.InteropServices; using Microsoft.CodeAnalysis; namespace Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace; @@ -11,15 +10,12 @@ public class RazorWorkspaceListener : IDisposable private static readonly TimeSpan s_debounceTime = TimeSpan.FromMilliseconds(100); private string? _projectRazorJsonFileName; - private readonly Dictionary _workQueues; + private readonly Dictionary _workQueues; private readonly object _gate = new(); public RazorWorkspaceListener() { - var comparer = RuntimeInformation.IsOSPlatform(OSPlatform.Linux) - ? StringComparer.Ordinal - : StringComparer.OrdinalIgnoreCase; - _workQueues = new Dictionary(comparer); + _workQueues = new Dictionary(); } public void EnsureInitialized(Workspace workspace, string projectRazorJsonFileName) @@ -38,21 +34,39 @@ private void Workspace_WorkspaceChanged(object? sender, WorkspaceChangeEventArgs { switch (e.Kind) { - case WorkspaceChangeKind.SolutionAdded: case WorkspaceChangeKind.SolutionChanged: case WorkspaceChangeKind.SolutionReloaded: + foreach (var project in e.OldSolution.Projects) + { + RemoveProject(project); + } + + foreach (var project in e.NewSolution.Projects) + { + EnqueueUpdate(project); + } + + break; + + case WorkspaceChangeKind.SolutionAdded: foreach (var project in e.NewSolution.Projects) { EnqueueUpdate(project); } break; + case WorkspaceChangeKind.ProjectRemoved: RemoveProject(e.OldSolution.GetProject(e.ProjectId)); break; + + case WorkspaceChangeKind.ProjectReloaded: + RemoveProject(e.OldSolution.GetProject(e.ProjectId)); + EnqueueUpdate(e.NewSolution.GetProject(e.ProjectId)); + break; + case WorkspaceChangeKind.ProjectAdded: case WorkspaceChangeKind.ProjectChanged: - case WorkspaceChangeKind.ProjectReloaded: case WorkspaceChangeKind.DocumentAdded: case WorkspaceChangeKind.DocumentRemoved: case WorkspaceChangeKind.DocumentReloaded: @@ -66,7 +80,12 @@ private void Workspace_WorkspaceChanged(object? sender, WorkspaceChangeEventArgs case WorkspaceChangeKind.AnalyzerConfigDocumentRemoved: case WorkspaceChangeKind.AnalyzerConfigDocumentReloaded: case WorkspaceChangeKind.AnalyzerConfigDocumentChanged: - EnqueueUpdate(e.NewSolution.GetProject(e.ProjectId)); + var projectId = e.ProjectId ?? e.DocumentId?.ProjectId; + if (projectId is not null) + { + EnqueueUpdate(e.NewSolution.GetProject(projectId)); + } + break; case WorkspaceChangeKind.SolutionCleared: case WorkspaceChangeKind.SolutionRemoved: @@ -83,7 +102,7 @@ private void Workspace_WorkspaceChanged(object? sender, WorkspaceChangeEventArgs private void RemoveProject(Project? project) { - if (project?.FilePath is null) + if (project is null) { return; } @@ -91,9 +110,9 @@ private void RemoveProject(Project? project) TaskDelayScheduler? scheduler; lock (_gate) { - if (_workQueues.TryGetValue(project.FilePath, out scheduler)) + if (_workQueues.TryGetValue(project.Id, out scheduler)) { - _workQueues.Remove(project.FilePath); + _workQueues.Remove(project.Id); } } @@ -105,7 +124,6 @@ private void EnqueueUpdate(Project? project) if (_projectRazorJsonFileName is null || project is not { - FilePath: not null, Language: LanguageNames.CSharp }) { @@ -115,13 +133,25 @@ project is not TaskDelayScheduler? scheduler; lock (_gate) { - if (!_workQueues.TryGetValue(project.FilePath, out scheduler)) + if (!_workQueues.TryGetValue(project.Id, out scheduler)) { scheduler = new TaskDelayScheduler(s_debounceTime, CancellationToken.None); + _workQueues.Add(project.Id, scheduler); } } - scheduler.ScheduleAsyncTask(ct => RazorProjectJsonSerializer.SerializeAsync(project, _projectRazorJsonFileName, ct), CancellationToken.None); + scheduler.ScheduleAsyncTask(ct => SerializeProjectAsync(project, ct), CancellationToken.None); + } + + // Protected for testing + protected virtual Task SerializeProjectAsync(Project project, CancellationToken ct) + { + if (_projectRazorJsonFileName is null) + { + return Task.CompletedTask; + } + + return RazorProjectJsonSerializer.SerializeAsync(project, _projectRazorJsonFileName, ct); } public void Dispose() diff --git a/src/Razor/test/Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace.Test/Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace.Test.csproj b/src/Razor/test/Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace.Test/Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace.Test.csproj new file mode 100644 index 00000000000..79b53b1aa53 --- /dev/null +++ b/src/Razor/test/Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace.Test/Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace.Test.csproj @@ -0,0 +1,17 @@ + + + + $(DefaultNetCoreTargetFrameworks) + + + + + + + + + + + + + diff --git a/src/Razor/test/Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace.Test/RazorWorkspaceListenerTest.cs b/src/Razor/test/Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace.Test/RazorWorkspaceListenerTest.cs new file mode 100644 index 00000000000..f94be839a26 --- /dev/null +++ b/src/Razor/test/Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace.Test/RazorWorkspaceListenerTest.cs @@ -0,0 +1,141 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Host; +using Microsoft.CodeAnalysis.Text; +using Xunit; + +namespace Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace.Test; + +public class RazorWorkspaceListenerTest +{ + [Fact] + public async Task ProjectAdded_SchedulesTask() + { + using var workspace = new AdhocWorkspace(CodeAnalysis.Host.Mef.MefHostServices.DefaultHost); + + using var listener = new TestRazorWorkspaceListener(); + listener.EnsureInitialized(workspace, "temp.json"); + + var project = workspace.AddProject("TestProject", LanguageNames.CSharp); + + // Wait for debounce + await Task.Delay(500); + + Assert.Equal(1, listener.SerializeCalls[project.Id]); + } + + [Fact] + public async Task TwoProjectsAdded_SchedulesTwoTasks() + { + using var workspace = new AdhocWorkspace(CodeAnalysis.Host.Mef.MefHostServices.DefaultHost); + + using var listener = new TestRazorWorkspaceListener(); + listener.EnsureInitialized(workspace, "temp.json"); + + var project1 = workspace.AddProject("TestProject1", LanguageNames.CSharp); + var project2 = workspace.AddProject("TestProject2", LanguageNames.CSharp); + + // Wait for debounce + await Task.Delay(500); + + // These are different projects, so should cause two calls + Assert.Equal(1, listener.SerializeCalls[project1.Id]); + Assert.Equal(1, listener.SerializeCalls[project2.Id]); + } + + [Fact] + public async Task ProjectAddedAndRemoved_NoTasks() + { + using var workspace = new AdhocWorkspace(CodeAnalysis.Host.Mef.MefHostServices.DefaultHost); + + using var listener = new TestRazorWorkspaceListener(); + listener.EnsureInitialized(workspace, "temp.json"); + + var project = workspace.AddProject("TestProject", LanguageNames.CSharp); + var newSolution = project.Solution.RemoveProject(project.Id); + Assert.True(workspace.TryApplyChanges(newSolution)); + + // Wait for debounce + await Task.Delay(500); + + Assert.Empty(listener.SerializeCalls); + } + + [Fact] + public async Task TwoProjectChanges_SchedulesOneTasks() + { + using var workspace = new AdhocWorkspace(CodeAnalysis.Host.Mef.MefHostServices.DefaultHost); + + using var listener = new TestRazorWorkspaceListener(); + listener.EnsureInitialized(workspace, "temp.json"); + + var project = workspace.AddProject("TestProject", LanguageNames.CSharp); + var newSolution = project.Solution.WithProjectDefaultNamespace(project.Id, "NewDefaultNamespace"); + Assert.True(workspace.TryApplyChanges(newSolution)); + + // Wait for debounce + await Task.Delay(500); + + Assert.Equal(1, listener.SerializeCalls[project.Id]); + } + + [Fact] + public async Task DocumentAdded_SchedulesTask() + { + using var workspace = new AdhocWorkspace(CodeAnalysis.Host.Mef.MefHostServices.DefaultHost); + + using var listener = new TestRazorWorkspaceListener(); + listener.EnsureInitialized(workspace, "temp.json"); + + var project = workspace.AddProject("TestProject", LanguageNames.CSharp); + + workspace.AddDocument(project.Id, "Document", SourceText.From("Hi there")); + + // Wait for debounce + await Task.Delay(500); + + Assert.Equal(1, listener.SerializeCalls[project.Id]); + } + + [Fact] + public async Task DocumentAdded_WithDelay_SchedulesTwoTasks() + { + using var workspace = new AdhocWorkspace(CodeAnalysis.Host.Mef.MefHostServices.DefaultHost); + + using var listener = new TestRazorWorkspaceListener(); + listener.EnsureInitialized(workspace, "temp.json"); + + var project = workspace.AddProject("TestProject", LanguageNames.CSharp); + + // Wait for debounce + await Task.Delay(500); + + workspace.AddDocument(project.Id, "Document", SourceText.From("Hi there")); + + // Wait for debounce + await Task.Delay(500); + + Assert.Equal(2, listener.SerializeCalls[project.Id]); + } + + private class TestRazorWorkspaceListener : RazorWorkspaceListener + { + private Dictionary _serializeCalls = new(); + + public Dictionary SerializeCalls => _serializeCalls; + + protected override Task SerializeProjectAsync(Project project, CancellationToken ct) + { + if (!_serializeCalls.ContainsKey(project.Id)) + { + _serializeCalls.Add(project.Id, 0); + } + + _serializeCalls[project.Id]++; + + return Task.CompletedTask; + } + } +} diff --git a/src/Razor/test/Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace.Test/xunit.runner.json b/src/Razor/test/Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace.Test/xunit.runner.json new file mode 100644 index 00000000000..4b6534f94d4 --- /dev/null +++ b/src/Razor/test/Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace.Test/xunit.runner.json @@ -0,0 +1,5 @@ +{ + "methodDisplay": "method", + "shadowCopy": false, + "parallelizeTestCollections": false +} \ No newline at end of file