/
githubmirror
/
roslyn
Обзор
Документация
Войти
/
githubmirror
/
roslyn
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/MiscellaneousFiles/FileBasedProgramsEntryPointDiscoveryTests.cs
1 047 строк
47 KB
Jan Jones
Allow MSBuildWorkspace to open file-based apps (#84139)
30 июл 2026, 11:03
Не верифицирован
30 июл 2026, 11:03
7e7ebef
Код
Авторство
О чём код?
// 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 System.Reflection; using Microsoft.CodeAnalysis.FileBasedPrograms; using Microsoft.CodeAnalysis.Host; using Microsoft.CodeAnalysis.LanguageServer.FileBasedPrograms; using Microsoft.CodeAnalysis.LanguageServer.HostWorkspace; using Microsoft.CodeAnalysis.Shared.Extensions; using Microsoft.CodeAnalysis.Shared.TestHooks; using Microsoft.CodeAnalysis.Test.Utilities; using Microsoft.CodeAnalysis.Text; using Microsoft.CodeAnalysis.UnitTests; using Microsoft.Extensions.Logging; using Microsoft.VisualStudio.Composition; using Roslyn.LanguageServer.Protocol; using Roslyn.Test.Utilities; using Roslyn.Utilities; using Xunit.Abstractions; namespace Microsoft.CodeAnalysis.LanguageServer.UnitTests.FileBasedPrograms; public sealed class FileBasedProgramsEntryPointDiscoveryTests : AbstractLanguageServerProtocolTests, IDisposable { private readonly ITestOutputHelper _testOutputHelper; private readonly ILoggerFactory _loggerFactory; private readonly TestOutputLoggerProvider _loggerProvider; private readonly TempRoot _tempRoot; private readonly List<string> _additionalDirectoriesToDelete = []; public FileBasedProgramsEntryPointDiscoveryTests(ITestOutputHelper testOutputHelper) : base(testOutputHelper) { _testOutputHelper = testOutputHelper; _loggerProvider = new TestOutputLoggerProvider(testOutputHelper); _loggerFactory = new LoggerFactory([_loggerProvider]); _tempRoot = new(); } protected override ValueTask<ExportProvider> CreateExportProviderAsync() { AsynchronousOperationListenerProvider.Enable(enable: true); return new(LanguageServerTestComposition.GetSharedExportProvider(AbstractLanguageServerHostTests.DefaultServerConfiguration, _loggerFactory)); } public void Dispose() { _tempRoot.Dispose(); _loggerProvider.Dispose(); _loggerFactory.Dispose(); foreach (var directory in _additionalDirectoriesToDelete) { if (Directory.Exists(directory)) Directory.Delete(directory, recursive: true); } } private void DeferDeleteCacheDirectory(TestLspServer testLspServer, string workspacePath) { var fileBasedProgramService = testLspServer.GetRequiredLspService<IHostWorkspaceProvider>().Workspace.Services.GetRequiredService<IFileBasedProgramService>(); var cacheDirectory = fileBasedProgramService.GetDiscoveryCacheDirectory(workspacePath); _additionalDirectoriesToDelete.Add(cacheDirectory); } /// <summary>Verify that multiple invocations of 'actualFactory' result in the same 'expected' sequence.</summary> private void AssertSequenceEqualAndStable<T>(IEnumerable<T> expected, Func<IEnumerable<T>> actualFactory) { AssertEx.SequenceEqual(expected, actualFactory()); AssertEx.SequenceEqual(expected, actualFactory()); } [Fact] public async Task TestDiscovery_Simple() { // Simple case // tempDir/ // App.cs // Ordinary.cs var tempDir = _tempRoot.CreateDirectory(); var appText = """ #!/usr/bin/env dotnet #:sdk Microsoft.NET.Sdk Console.WriteLine("Hello World"); """; var appFile = tempDir.CreateFile("App.cs").WriteAllText(appText); // Note: having '#:' is not enough for discovery to detect a file. The file needs to start with '#!'. var ordinaryText = """ #:sdk Microsoft.NET.Sdk public class Ordinary { } """; var ordinaryFile = tempDir.CreateFile("Ordinary.cs").WriteAllText(ordinaryText); await using var testLspServer = await CreateDiscoveryTestServerAsync(tempDir.Path); var discovery = testLspServer.GetRequiredLspService<FileBasedProgramsEntryPointDiscovery>(); AssertSequenceEqualAndStable([appFile.Path], () => discovery.FindEntryPoints(tempDir.Path)); // Changed but still has '#!' appFile.WriteAllText(appText + """ Console.WriteLine("Additional content"); """); AssertEx.SequenceEqual([appFile.Path], discovery.FindEntryPoints(tempDir.Path)); // Deleted from disk File.Delete(appFile.Path); AssertEx.Empty(discovery.FindEntryPoints(tempDir.Path)); // Put back on disk appFile.WriteAllText(appText); AssertEx.SequenceEqual([appFile.Path], discovery.FindEntryPoints(tempDir.Path)); // Changed and no longer has '#!' appFile.WriteAllText(""" Console.WriteLine("No more #! at start of file"); """); AssertEx.Empty(discovery.FindEntryPoints(tempDir.Path)); // Changed and again has '#!' appFile.WriteAllText(appText); AssertEx.SequenceEqual([appFile.Path], discovery.FindEntryPoints(tempDir.Path)); } [Fact] public async Task TestDiscovery_IgnoredFolders() { // Demonstrate ignored folders behavior // tempDir/ // artifacts/App1.cs // App2.cs var tempDir = _tempRoot.CreateDirectory(); var artifactsDir = tempDir.CreateDirectory("artifacts"); var app1Text = """ #!/usr/bin/env dotnet #:sdk Microsoft.NET.Sdk Console.WriteLine("Hello World"); """; var app1File = artifactsDir.CreateFile("App1.cs").WriteAllText(app1Text); var app2Text = app1Text; var app2File = tempDir.CreateFile("App2.cs").WriteAllText(app2Text); await using var testLspServer = await CreateDiscoveryTestServerAsync(tempDir.Path); var discovery = testLspServer.GetRequiredLspService<FileBasedProgramsEntryPointDiscovery>(); AssertSequenceEqualAndStable([app2File.Path], () => discovery.FindEntryPoints(tempDir.Path)); } [Fact] public async Task TestDiscovery_DotPrefixedFolders() { // Demonstrate that directories starting with '.' are ignored // tempDir/ // .git/App1.cs // .vs/App2.cs // .config/App3.cs // App4.cs var tempDir = _tempRoot.CreateDirectory(); var appText = """ #!/usr/bin/env dotnet #:sdk Microsoft.NET.Sdk Console.WriteLine("Hello World"); """; var gitDir = tempDir.CreateDirectory(".git"); var app1File = gitDir.CreateFile("App1.cs").WriteAllText(appText); var vsDir = tempDir.CreateDirectory(".vs"); var app2File = vsDir.CreateFile("App2.cs").WriteAllText(appText); var configDir = tempDir.CreateDirectory(".config"); var app3File = configDir.CreateFile("App3.cs").WriteAllText(appText); var app4File = tempDir.CreateFile("App4.cs").WriteAllText(appText); await using var testLspServer = await CreateDiscoveryTestServerAsync(tempDir.Path); var discovery = testLspServer.GetRequiredLspService<FileBasedProgramsEntryPointDiscovery>(); AssertSequenceEqualAndStable([app4File.Path], () => discovery.FindEntryPoints(tempDir.Path)); } [Fact] public async Task TestDiscovery_NestedDotPrefixedFolders() { // Demonstrate that nested directories starting with '.' are also ignored // tempDir/ // subdir/ // .hidden/App1.cs // App2.cs var tempDir = _tempRoot.CreateDirectory(); var appText = """ #!/usr/bin/env dotnet #:sdk Microsoft.NET.Sdk Console.WriteLine("Hello World"); """; var subDir = tempDir.CreateDirectory("subdir"); var hiddenDir = subDir.CreateDirectory(".hidden"); var app1File = hiddenDir.CreateFile("App1.cs").WriteAllText(appText); var app2File = subDir.CreateFile("App2.cs").WriteAllText(appText); await using var testLspServer = await CreateDiscoveryTestServerAsync(tempDir.Path); var discovery = testLspServer.GetRequiredLspService<FileBasedProgramsEntryPointDiscovery>(); AssertSequenceEqualAndStable([app2File.Path], () => discovery.FindEntryPoints(tempDir.Path)); } [Fact] public async Task TestDiscovery_CsprojInCone() { // Demonstrate csproj-in-cone behavior // tempDir/ // Project/ // Project.csproj // Program.cs // App.cs var tempDir = _tempRoot.CreateDirectory(); var projectDir = tempDir.CreateDirectory("Project"); var csprojFile = projectDir.CreateFile("Project.csproj"); var appText = """ #!/usr/bin/env dotnet #:sdk Microsoft.NET.Sdk Console.WriteLine("Hello World"); """; var programFile = projectDir.CreateFile("Program.cs").WriteAllText(appText); var appFile = tempDir.CreateFile("App1.cs").WriteAllText(appText); await using var testLspServer = await CreateDiscoveryTestServerAsync(tempDir.Path); var discovery = testLspServer.GetRequiredLspService<FileBasedProgramsEntryPointDiscovery>(); AssertSequenceEqualAndStable([appFile.Path], () => discovery.FindEntryPoints(tempDir.Path)); // Delete the csproj file File.Delete(csprojFile.Path); AssertSequenceEqualAndStable([appFile.Path, programFile.Path], () => discovery.FindEntryPoints(tempDir.Path)); } [Fact] public async Task TestDiscovery_Option_EnableFileBasedPrograms_True() { // Ensure discovery occurs when relevant options are enabled // Note: the option is checked in the higher level API, so we need to verify the effects in project system. var tempDir = _tempRoot.CreateDirectory(); var appText = """ #!/usr/bin/env dotnet #:sdk Microsoft.NET.Sdk Console.WriteLine("Hello World"); """; var appFile = tempDir.CreateFile("App1.cs").WriteAllText(appText); await using var testLspServer = await CreateTestLspServerAsync(string.Empty, mutatingLspWorkspace: false, new InitializationOptions { ServerKind = WellKnownLspServerKinds.CSharpVisualBasicLspServer, OptionUpdater = options => options.SetGlobalOption(LanguageServerProjectSystemOptionsStorage.EnableFileBasedPrograms, true), WorkspaceFolders = [ new() { DocumentUri = CreateAbsoluteDocumentUri(tempDir.Path), Name = "workspace1" } ] }); DeferDeleteCacheDirectory(testLspServer, tempDir.Path); var discovery = testLspServer.GetRequiredLspService<FileBasedProgramsEntryPointDiscovery>(); await discovery.FindAndLoadEntryPointsAsync(); await testLspServer.TestWorkspace.GetService<AsynchronousOperationListenerProvider>().GetWaiter(FeatureAttribute.Workspace).ExpeditedWaitAsync(); var (workspace, document) = await GetRequiredLspWorkspaceAndDocumentAsync(CreateAbsoluteDocumentUri(appFile.Path), testLspServer); Assert.Equal(WorkspaceKind.Host, workspace.Kind); Assert.NotNull(document); } [Fact] public async Task TestDiscovery_Option_EnableFileBasedPrograms_False() { // Ensure discovery doesn't occur when 'dotnet.projects.enableFileBasedPrograms: false' is set // Note: the option is checked in the higher level API, so we need to verify the effects in project system. var tempDir = _tempRoot.CreateDirectory(); var appText = """ #!/usr/bin/env dotnet #:sdk Microsoft.NET.Sdk Console.WriteLine("Hello World"); """; var appFile = tempDir.CreateFile("App1.cs").WriteAllText(appText); await using var testLspServer = await CreateTestLspServerAsync(string.Empty, mutatingLspWorkspace: false, new InitializationOptions { ServerKind = WellKnownLspServerKinds.CSharpVisualBasicLspServer, OptionUpdater = options => options.SetGlobalOption(LanguageServerProjectSystemOptionsStorage.EnableFileBasedPrograms, false), WorkspaceFolders = [ new() { DocumentUri = CreateAbsoluteDocumentUri(tempDir.Path), Name = "workspace1" } ] }); DeferDeleteCacheDirectory(testLspServer, tempDir.Path); var discovery = testLspServer.GetRequiredLspService<FileBasedProgramsEntryPointDiscovery>(); await discovery.FindAndLoadEntryPointsAsync(); await testLspServer.TestWorkspace.GetService<AsynchronousOperationListenerProvider>().GetWaiter(FeatureAttribute.Workspace).ExpeditedWaitAsync(); var (workspace, document) = await GetLspWorkspaceAndDocumentAsync(CreateAbsoluteDocumentUri(appFile.Path), testLspServer); Assert.Null(workspace); Assert.Null(document); } [Fact] public async Task TestDiscovery_Option_EnableAutomaticDiscovery_False() { // Ensure discovery doesn't occur when 'dotnet.fileBasedApps.enableAutomaticDiscovery: false' is set // Note: the option is checked in the higher level API, so we need to verify the effects in project system. var tempDir = _tempRoot.CreateDirectory(); var appText = """ #!/usr/bin/env dotnet #:sdk Microsoft.NET.Sdk Console.WriteLine("Hello World"); """; var appFile = tempDir.CreateFile("App1.cs").WriteAllText(appText); await using var testLspServer = await CreateDiscoveryTestServerAsync(tempDir.Path); var discovery = testLspServer.GetRequiredLspService<FileBasedProgramsEntryPointDiscovery>(); await discovery.FindAndLoadEntryPointsAsync(); await testLspServer.TestWorkspace.GetService<AsynchronousOperationListenerProvider>().GetWaiter(FeatureAttribute.Workspace).ExpeditedWaitAsync(); var (workspace, document) = await GetLspWorkspaceAndDocumentAsync(CreateAbsoluteDocumentUri(appFile.Path), testLspServer); Assert.Null(workspace); Assert.Null(document); } [Fact] public async Task TestDiscovery_UTF8_BOM() { // File starting with UTF-8 BOM followed by '#!' should be discovered var tempDir = _tempRoot.CreateDirectory(); var appText = """ #!/usr/bin/env dotnet #:sdk Microsoft.NET.Sdk Console.WriteLine("Hello World"); """; var bomAppText = "\uFEFF" + appText; var appFile = tempDir.CreateFile("App.cs").WriteAllText(bomAppText); var ordinaryFile = tempDir.CreateFile("Ordinary.cs").WriteAllText("public class Ordinary { }"); await using var testLspServer = await CreateDiscoveryTestServerAsync(tempDir.Path); var discovery = testLspServer.GetRequiredLspService<FileBasedProgramsEntryPointDiscovery>(); AssertEx.SequenceEqual([appFile.Path], discovery.FindEntryPoints(tempDir.Path)); } private async Task<TestLspServer> CreateDiscoveryTestServerAsync(string workspacePath) { var testLspServer = await CreateTestLspServerAsync(string.Empty, mutatingLspWorkspace: false, new InitializationOptions { ServerKind = WellKnownLspServerKinds.CSharpVisualBasicLspServer, // Disable background discovery so it doesn't race with direct FindEntryPoints calls. OptionUpdater = options => options.SetGlobalOption(FileBasedAppsOptionsStorage.EnableAutomaticDiscovery, false), WorkspaceFolders = [ new() { DocumentUri = CreateAbsoluteDocumentUri(workspacePath), Name = "workspace1" } ] }); DeferDeleteCacheDirectory(testLspServer, workspacePath); return testLspServer; } private static async Task<(Workspace? workspace, Document? document)> GetLspWorkspaceAndDocumentAsync(DocumentUri uri, TestLspServer testLspServer) { var (workspace, _, document) = await testLspServer.GetManager().GetLspDocumentInfoAsync(CreateTextDocumentIdentifier(uri), CancellationToken.None).ConfigureAwait(false); return (workspace, document as Document); } private static async Task<(Workspace workspace, Document document)> GetRequiredLspWorkspaceAndDocumentAsync(DocumentUri uri, TestLspServer testLspServer) { var (workspace, document) = await GetLspWorkspaceAndDocumentAsync(uri, testLspServer); Assert.NotNull(workspace); Assert.NotNull(document); return (workspace, document); } [Fact] public async Task Swap_ReplaceFBAWithNonFBA() { // Swap an FBA out for non-FBA at the same path 'sub1/File1.cs'. var tempDir = _tempRoot.CreateDirectory(); await using var testLspServer = await CreateDiscoveryTestServerAsync(tempDir.Path); var discovery = testLspServer.GetRequiredLspService<FileBasedProgramsEntryPointDiscovery>(); // Setup Directory.CreateDirectory(Path.Combine(tempDir.Path, @"sub1")); File.WriteAllText(Path.Combine(tempDir.Path, @"sub1/File1.cs"), FbaContent); File.WriteAllText(Path.Combine(tempDir.Path, @"sub1/File2.cs"), OrdinaryCsContent); // First discovery (no cache) var firstResult = discovery.FindEntryPoints(tempDir.Path).ToArray(); // Edits File.Move(Path.Combine(tempDir.Path, @"sub1/File1.cs"), Path.Combine(tempDir.Path, @"sub1/File4.cs")); File.Move(Path.Combine(tempDir.Path, @"sub1/File2.cs"), Path.Combine(tempDir.Path, @"sub1/File1.cs")); // Discovery with cache var cachedResult = discovery.FindEntryPoints(tempDir.Path).Order(StringComparer.OrdinalIgnoreCase).ToArray(); // Delete cache var fileBasedProgramService = testLspServer.GetRequiredLspService<IHostWorkspaceProvider>().Workspace.Services.GetRequiredService<IFileBasedProgramService>(); var cacheDirectory = fileBasedProgramService.GetDiscoveryCacheDirectory(tempDir.Path); Directory.Delete(cacheDirectory, recursive: true); // Discovery without cache - should match var uncachedResult = discovery.FindEntryPoints(tempDir.Path).Order(StringComparer.OrdinalIgnoreCase).ToArray(); AssertEx.SequenceEqual(uncachedResult, cachedResult, StringComparer.OrdinalIgnoreCase); } [Fact] public async Task Swap_ReplaceNonFBAWithFBA() { // Swap a non-FBA out for FBA at the same path 'sub/File1.cs'. var tempDir = _tempRoot.CreateDirectory(); await using var testLspServer = await CreateDiscoveryTestServerAsync(tempDir.Path); var discovery = testLspServer.GetRequiredLspService<FileBasedProgramsEntryPointDiscovery>(); // Setup Directory.CreateDirectory(Path.Combine(tempDir.Path, @"sub1")); File.WriteAllText(Path.Combine(tempDir.Path, @"sub1/File1.cs"), OrdinaryCsContent); File.WriteAllText(Path.Combine(tempDir.Path, @"sub1/File2.cs"), FbaContent); // First discovery (no cache) var firstResult = discovery.FindEntryPoints(tempDir.Path).ToArray(); // Edits File.Move(Path.Combine(tempDir.Path, @"sub1/File1.cs"), Path.Combine(tempDir.Path, @"sub1/File4.cs")); File.Move(Path.Combine(tempDir.Path, @"sub1/File2.cs"), Path.Combine(tempDir.Path, @"sub1/File1.cs")); // Discovery with cache var cachedResult = discovery.FindEntryPoints(tempDir.Path).Order(StringComparer.OrdinalIgnoreCase).ToArray(); // Delete cache var fileBasedProgramService = testLspServer.GetRequiredLspService<IHostWorkspaceProvider>().Workspace.Services.GetRequiredService<IFileBasedProgramService>(); var cacheDirectory = fileBasedProgramService.GetDiscoveryCacheDirectory(tempDir.Path); Directory.Delete(cacheDirectory, recursive: true); // Discovery without cache — should match var uncachedResult = discovery.FindEntryPoints(tempDir.Path).Order(StringComparer.OrdinalIgnoreCase).ToArray(); AssertEx.SequenceEqual(uncachedResult, cachedResult, StringComparer.OrdinalIgnoreCase); } [Fact] public async Task Swap_ReplaceFBADirectoryWithNonFBADirectory() { // Swap a directory containing FBA out for a directory containing non-FBA at 'sub1/File1.cs'. var tempDir = _tempRoot.CreateDirectory(); await using var testLspServer = await CreateDiscoveryTestServerAsync(tempDir.Path); var discovery = testLspServer.GetRequiredLspService<FileBasedProgramsEntryPointDiscovery>(); // Setup Directory.CreateDirectory(Path.Combine(tempDir.Path, @"sub1")); Directory.CreateDirectory(Path.Combine(tempDir.Path, @"sub2")); File.WriteAllText(Path.Combine(tempDir.Path, @"sub1/File1.cs"), FbaContent); File.WriteAllText(Path.Combine(tempDir.Path, @"sub2/File1.cs"), OrdinaryCsContent); // First discovery (no cache) var firstResult = discovery.FindEntryPoints(tempDir.Path).ToArray(); // Edits Directory.Move(Path.Combine(tempDir.Path, @"sub1"), Path.Combine(tempDir.Path, @"sub4")); Directory.Move(Path.Combine(tempDir.Path, @"sub2"), Path.Combine(tempDir.Path, @"sub1")); // Discovery with cache - should match var cachedResult = discovery.FindEntryPoints(tempDir.Path).Order(StringComparer.OrdinalIgnoreCase).ToArray(); // Delete cache var fileBasedProgramService = testLspServer.GetRequiredLspService<IHostWorkspaceProvider>().Workspace.Services.GetRequiredService<IFileBasedProgramService>(); var cacheDirectory = fileBasedProgramService.GetDiscoveryCacheDirectory(tempDir.Path); Directory.Delete(cacheDirectory, recursive: true); // Discovery without cache var uncachedResult = discovery.FindEntryPoints(tempDir.Path).Order(StringComparer.OrdinalIgnoreCase).ToArray(); AssertEx.SequenceEqual(uncachedResult, cachedResult, StringComparer.OrdinalIgnoreCase); } [Fact] public async Task Swap_ReplaceNonFBADirectoryWithFBADirectory() { // Swap a directory containing non-FBA out for a directory containing FBA at the same path 'sub1/File1.cs'. var tempDir = _tempRoot.CreateDirectory(); await using var testLspServer = await CreateDiscoveryTestServerAsync(tempDir.Path); var discovery = testLspServer.GetRequiredLspService<FileBasedProgramsEntryPointDiscovery>(); // Setup Directory.CreateDirectory(Path.Combine(tempDir.Path, @"sub1")); Directory.CreateDirectory(Path.Combine(tempDir.Path, @"sub2")); File.WriteAllText(Path.Combine(tempDir.Path, @"sub1/File1.cs"), OrdinaryCsContent); File.WriteAllText(Path.Combine(tempDir.Path, @"sub2/File1.cs"), FbaContent); // First discovery (no cache) var firstResult = discovery.FindEntryPoints(tempDir.Path).ToArray(); // Edits Directory.Move(Path.Combine(tempDir.Path, @"sub1"), Path.Combine(tempDir.Path, @"sub4")); Directory.Move(Path.Combine(tempDir.Path, @"sub2"), Path.Combine(tempDir.Path, @"sub1")); // Discovery with cache var cachedResult = discovery.FindEntryPoints(tempDir.Path).Order(StringComparer.OrdinalIgnoreCase).ToArray(); // Delete cache var fileBasedProgramService = testLspServer.GetRequiredLspService<IHostWorkspaceProvider>().Workspace.Services.GetRequiredService<IFileBasedProgramService>(); var cacheDirectory = fileBasedProgramService.GetDiscoveryCacheDirectory(tempDir.Path); Directory.Delete(cacheDirectory, recursive: true); // Discovery without cache — should match var uncachedResult = discovery.FindEntryPoints(tempDir.Path).Order(StringComparer.OrdinalIgnoreCase).ToArray(); AssertEx.SequenceEqual(uncachedResult, cachedResult, StringComparer.OrdinalIgnoreCase); } [Fact] public async Task Fuzz_1() { var tempDir = _tempRoot.CreateDirectory(); await using var testLspServer = await CreateDiscoveryTestServerAsync(tempDir.Path); var discovery = testLspServer.GetRequiredLspService<FileBasedProgramsEntryPointDiscovery>(); // Setup File.WriteAllText(Path.Combine(tempDir.Path, @"Fba0.cs"), FbaContent); File.WriteAllText(Path.Combine(tempDir.Path, @"Fba1.cs"), FbaContent); File.WriteAllText(Path.Combine(tempDir.Path, @"Ordinary2.cs"), OrdinaryCsContent); // First discovery (no cache) var firstResult = discovery.FindEntryPoints(tempDir.Path).ToArray(); // Edits File.WriteAllText(Path.Combine(tempDir.Path, @"New102.csproj"), CsprojContent); File.Delete(Path.Combine(tempDir.Path, @"Fba0.cs")); File.WriteAllText(Path.Combine(tempDir.Path, @"NewOrd22.cs"), OrdinaryCsContent); File.WriteAllText(Path.Combine(tempDir.Path, @"Ordinary2.cs"), OrdinaryCsContent); File.WriteAllText(Path.Combine(tempDir.Path, @"Ordinary2.cs"), FbaContent); File.WriteAllText(Path.Combine(tempDir.Path, @"NewOrd5.cs"), OrdinaryCsContent); File.WriteAllText(Path.Combine(tempDir.Path, @"New79.csproj"), CsprojContent); // Discovery with cache var cachedResult = discovery.FindEntryPoints(tempDir.Path).Order(StringComparer.OrdinalIgnoreCase).ToArray(); // Delete cache var fileBasedProgramService = testLspServer.GetRequiredLspService<IHostWorkspaceProvider>().Workspace.Services.GetRequiredService<IFileBasedProgramService>(); var cacheDirectory = fileBasedProgramService.GetDiscoveryCacheDirectory(tempDir.Path); Directory.Delete(cacheDirectory, recursive: true); // Discovery without cache — should match var uncachedResult = discovery.FindEntryPoints(tempDir.Path).Order(StringComparer.OrdinalIgnoreCase).ToArray(); AssertEx.SequenceEqual(cachedResult, uncachedResult, StringComparer.OrdinalIgnoreCase); } [Fact] public async Task Fuzz_2() { var tempDir = _tempRoot.CreateDirectory(); await using var testLspServer = await CreateDiscoveryTestServerAsync(tempDir.Path); var discovery = testLspServer.GetRequiredLspService<FileBasedProgramsEntryPointDiscovery>(); // Setup File.WriteAllText(Path.Combine(tempDir.Path, @"Fba0.cs"), FbaContent); Directory.CreateDirectory(Path.Combine(tempDir.Path, @"deep/nested")); File.WriteAllText(Path.Combine(tempDir.Path, @"deep/nested/Fba1.cs"), FbaContent); Directory.CreateDirectory(Path.Combine(tempDir.Path, @"deep/nested")); File.WriteAllText(Path.Combine(tempDir.Path, @"deep/nested/Project2.csproj"), CsprojContent); File.WriteAllText(Path.Combine(tempDir.Path, @"Project3.csproj"), CsprojContent); Directory.CreateDirectory(Path.Combine(tempDir.Path, @"deep/nested/sub3")); // First discovery (no cache) var firstResult = discovery.FindEntryPoints(tempDir.Path).ToArray(); // Edits File.WriteAllText(Path.Combine(tempDir.Path, @"NewOrd40.cs"), OrdinaryCsContent); File.WriteAllText(Path.Combine(tempDir.Path, @"deep/nested/sub3/New52.csproj"), CsprojContent); File.WriteAllText(Path.Combine(tempDir.Path, @"deep/nested/NewOrd20.cs"), OrdinaryCsContent); File.WriteAllText(Path.Combine(tempDir.Path, @"deep/nested/Fba1.cs"), OrdinaryCsContent); // Discovery with cache var cachedResult = discovery.FindEntryPoints(tempDir.Path).Order(StringComparer.OrdinalIgnoreCase).ToArray(); // Delete cache var fileBasedProgramService = testLspServer.GetRequiredLspService<IHostWorkspaceProvider>().Workspace.Services.GetRequiredService<IFileBasedProgramService>(); var cacheDirectory = fileBasedProgramService.GetDiscoveryCacheDirectory(tempDir.Path); Directory.Delete(cacheDirectory, recursive: true); // Discovery without cache — should match var uncachedResult = discovery.FindEntryPoints(tempDir.Path).Order(StringComparer.OrdinalIgnoreCase).ToArray(); AssertEx.SequenceEqual(cachedResult, uncachedResult, StringComparer.OrdinalIgnoreCase); } [Fact] public async Task Fuzz_3() { var tempDir = _tempRoot.CreateDirectory(); await using var testLspServer = await CreateDiscoveryTestServerAsync(tempDir.Path); var discovery = testLspServer.GetRequiredLspService<FileBasedProgramsEntryPointDiscovery>(); // Setup Directory.CreateDirectory(Path.Combine(tempDir.Path, @"sub1")); Directory.CreateDirectory(Path.Combine(tempDir.Path, @"sub1/sub3")); File.WriteAllText(Path.Combine(tempDir.Path, @"Project0.csproj"), CsprojContent); File.WriteAllText(Path.Combine(tempDir.Path, @"sub1/sub3/Fba1.cs"), FbaContent); File.WriteAllText(Path.Combine(tempDir.Path, @"sub1/Fba2.cs"), FbaContent); File.WriteAllText(Path.Combine(tempDir.Path, @"sub1/Fba3.cs"), FbaContent); File.WriteAllText(Path.Combine(tempDir.Path, @"sub1/Ordinary4.cs"), OrdinaryCsContent); // First discovery (no cache) var firstResult = discovery.FindEntryPoints(tempDir.Path).ToArray(); // Edits File.Delete(Path.Combine(tempDir.Path, @"Project0.csproj")); File.WriteAllText(Path.Combine(tempDir.Path, @"sub1/sub3/NewFba64.cs"), FbaContent); // Discovery with cache var cachedResult = discovery.FindEntryPoints(tempDir.Path).Order(StringComparer.OrdinalIgnoreCase).ToArray(); // Delete cache var fileBasedProgramService = testLspServer.GetRequiredLspService<IHostWorkspaceProvider>().Workspace.Services.GetRequiredService<IFileBasedProgramService>(); var cacheDirectory = fileBasedProgramService.GetDiscoveryCacheDirectory(tempDir.Path); Directory.Delete(cacheDirectory, recursive: true); // Discovery without cache — should match var uncachedResult = discovery.FindEntryPoints(tempDir.Path).Order(StringComparer.OrdinalIgnoreCase).ToArray(); AssertEx.SequenceEqual(cachedResult, uncachedResult, StringComparer.OrdinalIgnoreCase); } #region Fuzzer private const string FbaContent = """ #!/usr/bin/env dotnet #:sdk Microsoft.NET.Sdk Console.WriteLine("hello"); """; private const string OrdinaryCsContent = """ public class C {} """; private const string CsprojContent = "<Project />"; /// <summary> /// Describes a single filesystem operation performed during a fuzz iteration. /// </summary> private abstract record FuzzOp { protected static string NormalizeForCSharp(string relativePath) => relativePath.Replace('\\', '/'); public abstract string ToCSharp(string tempDirVar); /// <summary>Creates a directory at the given relative path.</summary> internal sealed record CreateDir(string RelativePath) : FuzzOp { public override string ToCSharp(string tempDirVar) => $"Directory.CreateDirectory(Path.Combine({tempDirVar}.Path, @\"{NormalizeForCSharp(RelativePath)}\"));"; } /// <summary>Writes a .cs file with file-based-app content (starts with '#!').</summary> internal sealed record WriteFbaFile(string RelativePath) : FuzzOp { public override string ToCSharp(string tempDirVar) => $"File.WriteAllText(Path.Combine({tempDirVar}.Path, @\"{NormalizeForCSharp(RelativePath)}\"), FbaContent);"; } /// <summary>Writes a .cs file without file-based-app content (no '#!' at start).</summary> internal sealed record WriteOrdinaryCs(string RelativePath) : FuzzOp { public override string ToCSharp(string tempDirVar) => $"File.WriteAllText(Path.Combine({tempDirVar}.Path, @\"{NormalizeForCSharp(RelativePath)}\"), OrdinaryCsContent);"; } /// <summary>Writes a .csproj file.</summary> internal sealed record WriteCsproj(string RelativePath) : FuzzOp { public override string ToCSharp(string tempDirVar) => $"File.WriteAllText(Path.Combine({tempDirVar}.Path, @\"{NormalizeForCSharp(RelativePath)}\"), CsprojContent);"; } /// <summary>Deletes a file.</summary> internal sealed record DeleteFile(string RelativePath) : FuzzOp { public override string ToCSharp(string tempDirVar) => $"File.Delete(Path.Combine({tempDirVar}.Path, @\"{NormalizeForCSharp(RelativePath)}\"));"; } /// <summary>Renames/moves a file.</summary> internal sealed record RenameFile(string OldRelativePath, string NewRelativePath) : FuzzOp { public override string ToCSharp(string tempDirVar) => $"File.Move(Path.Combine({tempDirVar}.Path, @\"{NormalizeForCSharp(OldRelativePath)}\"), Path.Combine({tempDirVar}.Path, @\"{NormalizeForCSharp(NewRelativePath)}\"));"; } } /// <summary> /// Tracks what files exist in the virtual workspace to enable the fuzzer /// to generate valid operations (e.g. only delete files that exist). /// </summary> private sealed class FuzzWorkspace { private readonly string _rootPath; private readonly HashSet<string> _directories = new(StringComparer.OrdinalIgnoreCase); private readonly HashSet<string> _files = new(StringComparer.OrdinalIgnoreCase); public FuzzWorkspace(string rootPath) { _rootPath = rootPath; _directories.Add(""); // root } public IReadOnlyCollection<string> Directories => _directories; public IReadOnlyCollection<string> Files => _files; public string FullPath(string relativePath) => Path.Combine(_rootPath, relativePath); public void Apply(FuzzOp op) { switch (op) { case FuzzOp.CreateDir createDir: _directories.Add(createDir.RelativePath); Directory.CreateDirectory(FullPath(createDir.RelativePath)); break; case FuzzOp.WriteFbaFile writeFba: _files.Add(writeFba.RelativePath); File.WriteAllText(FullPath(writeFba.RelativePath), FbaContent); break; case FuzzOp.WriteOrdinaryCs writeCs: _files.Add(writeCs.RelativePath); File.WriteAllText(FullPath(writeCs.RelativePath), OrdinaryCsContent); break; case FuzzOp.WriteCsproj writeCsproj: _files.Add(writeCsproj.RelativePath); File.WriteAllText(FullPath(writeCsproj.RelativePath), CsprojContent); break; case FuzzOp.DeleteFile deleteFile: _files.Remove(deleteFile.RelativePath); File.Delete(FullPath(deleteFile.RelativePath)); break; case FuzzOp.RenameFile rename: _files.Remove(rename.OldRelativePath); _files.Add(rename.NewRelativePath); File.Move(FullPath(rename.OldRelativePath), FullPath(rename.NewRelativePath)); break; } } } private static readonly string[] s_dirNames = ["sub1", "sub2", "sub3", "deep" + Path.DirectorySeparatorChar + "nested"]; /// <summary> /// Generates a random "setup" operation (creating directories and files). /// </summary> private static FuzzOp GenerateSetupOp(Random random, FuzzWorkspace workspace) { // Weighted: create dirs early, then files var dirList = workspace.Directories.ToArray(); if (dirList.Length < 4 && random.Next(3) == 0) { // Create a subdirectory var parentDir = dirList[random.Next(dirList.Length)]; var name = s_dirNames[random.Next(s_dirNames.Length)]; var relativePath = parentDir.Length == 0 ? name : Path.Combine(parentDir, name); return new FuzzOp.CreateDir(relativePath); } // Create a file in a random directory var dir = dirList[random.Next(dirList.Length)]; var fileIndex = workspace.Files.Count; return random.Next(4) switch { 0 => new FuzzOp.WriteFbaFile(Path.Combine(dir, $"Fba{fileIndex}.cs")), 1 => new FuzzOp.WriteOrdinaryCs(Path.Combine(dir, $"Ordinary{fileIndex}.cs")), 2 => new FuzzOp.WriteCsproj(Path.Combine(dir, $"Project{fileIndex}.csproj")), _ => new FuzzOp.WriteFbaFile(Path.Combine(dir, $"Fba{fileIndex}.cs")), }; } /// <summary> /// Generates a random "edit" operation (modifying, deleting, renaming files, or creating/deleting csproj). /// </summary> private static FuzzOp? GenerateEditOp(Random random, FuzzWorkspace workspace) { var files = workspace.Files.ToArray(); if (files.Length == 0) return null; var dirList = workspace.Directories.ToArray(); var choice = random.Next(7); if (choice == 0) return new FuzzOp.DeleteFile(files[random.Next(files.Length)]); if (choice == 1) { var oldPath = files[random.Next(files.Length)]; var dir = dirList[random.Next(dirList.Length)]; var newPath = Path.Combine(dir, "moved_" + Path.GetFileName(oldPath)); if (workspace.Files.Contains(newPath)) return null; return new FuzzOp.RenameFile(oldPath, newPath); } if (choice == 2) return new FuzzOp.WriteFbaFile(Path.Combine(dirList[random.Next(dirList.Length)], $"NewFba{workspace.Files.Count + random.Next(100)}.cs")); if (choice == 3) return new FuzzOp.WriteOrdinaryCs(Path.Combine(dirList[random.Next(dirList.Length)], $"NewOrd{workspace.Files.Count + random.Next(100)}.cs")); if (choice == 4) return new FuzzOp.WriteCsproj(Path.Combine(dirList[random.Next(dirList.Length)], $"New{workspace.Files.Count + random.Next(100)}.csproj")); var csFiles = files.Where(f => f.EndsWith(".cs", StringComparison.OrdinalIgnoreCase)).ToArray(); if (csFiles.Length == 0) return null; if (choice == 5) return new FuzzOp.WriteFbaFile(csFiles[random.Next(csFiles.Length)]); if (choice == 6) return new FuzzOp.WriteOrdinaryCs(csFiles[random.Next(csFiles.Length)]); throw ExceptionUtilities.UnexpectedValue(choice); } [Fact] public async Task Fuzz() { // Explicitly seed the random so that if we need to manually edit and repro the fuzzing process locally, the logs will help us to do that var seed = Random.Shared.Next(); var random = new Random(seed); _testOutputHelper.WriteLine($"Random seed: {seed}"); var tempDir = _tempRoot.CreateDirectory(); await using var testLspServer = await CreateDiscoveryTestServerAsync(tempDir.Path); var discovery = testLspServer.GetRequiredLspService<FileBasedProgramsEntryPointDiscovery>(); for (var iteration = 0; iteration < 1000; iteration++) { var workspace = new FuzzWorkspace(tempDir.Path); var setupOps = new List<FuzzOp>(); var editOps = new List<FuzzOp>(); try { // Clean workspace for each iteration foreach (var entry in Directory.EnumerateFileSystemEntries(tempDir.Path)) { if (File.Exists(entry)) File.Delete(entry); else if (Directory.Exists(entry)) Directory.Delete(entry, recursive: true); } // Delete cache from any prior iteration var fileBasedProgramService = testLspServer.GetRequiredLspService<IHostWorkspaceProvider>().Workspace.Services.GetRequiredService<IFileBasedProgramService>(); var cacheDirectory = fileBasedProgramService.GetDiscoveryCacheDirectory(tempDir.Path); if (Directory.Exists(cacheDirectory)) Directory.Delete(cacheDirectory, recursive: true); // Step 1: Generate random initial filesystem var setupCount = random.Next(3, 12); for (var i = 0; i < setupCount; i++) { var op = GenerateSetupOp(random, workspace); setupOps.Add(op); workspace.Apply(op); } // Step 2: Discover entry points without cache var firstResult = discovery.FindEntryPoints(tempDir.Path).ToArray(); // Step 3: Random edits var editCount = random.Next(1, 8); for (var i = 0; i < editCount; i++) { var op = GenerateEditOp(random, workspace); if (op != null) { editOps.Add(op); workspace.Apply(op); } } // Step 4: Discover entry points using cache (cache was written by step 2) var cachedResult = discovery.FindEntryPoints(tempDir.Path).Order(StringComparer.OrdinalIgnoreCase).ToArray(); // Step 5: Delete the cache if (Directory.Exists(cacheDirectory)) Directory.Delete(cacheDirectory, recursive: true); // Step 6: Discover without cache — should match step 4 var uncachedResult = discovery.FindEntryPoints(tempDir.Path).Order(StringComparer.OrdinalIgnoreCase).ToArray(); AssertEx.SequenceEqual(uncachedResult, cachedResult, StringComparer.OrdinalIgnoreCase, $"Iteration {iteration}: Cached result differs from uncached result."); } catch (Exception) { // Dump reproducible test case DumpFuzzReproCase(iteration, setupOps, editOps); throw; } } } private void DumpFuzzReproCase(int iteration, List<FuzzOp> setupOps, List<FuzzOp> editOps) { _testOutputHelper.WriteLine(BuildFuzzReproCase(iteration, setupOps, editOps)); } private static string BuildFuzzReproCase(int iteration, List<FuzzOp> setupOps, List<FuzzOp> editOps) { var sb = new System.Text.StringBuilder(); sb.AppendLine($$""" [Fact] public async Task Fuzz_{{iteration}}() { var tempDir = _tempRoot.CreateDirectory(); await using var testLspServer = await CreateDiscoveryTestServerAsync(tempDir.Path); var discovery = testLspServer.GetRequiredLspService<FileBasedProgramsEntryPointDiscovery>(); // Setup """); foreach (var op in setupOps) sb.AppendLine($" {op.ToCSharp("tempDir")}"); sb.AppendLine(""" // First discovery (no cache) var firstResult = discovery.FindEntryPoints(tempDir.Path).ToArray(); // Edits """); foreach (var op in editOps) sb.AppendLine($" {op.ToCSharp("tempDir")}"); sb.AppendLine(""" // Discovery with cache var cachedResult = discovery.FindEntryPoints(tempDir.Path).Order(StringComparer.OrdinalIgnoreCase).ToArray(); // Delete cache var fileBasedProgramService = testLspServer.GetRequiredLspService<IHostWorkspaceProvider>().Workspace.Services.GetRequiredService<IFileBasedProgramService>(); var cacheDirectory = fileBasedProgramService.GetDiscoveryCacheDirectory(tempDir.Path); Directory.Delete(cacheDirectory, recursive: true); // Discovery without cache — should match var uncachedResult = discovery.FindEntryPoints(tempDir.Path).Order(StringComparer.OrdinalIgnoreCase).ToArray(); AssertEx.SequenceEqual(uncachedResult, cachedResult, StringComparer.OrdinalIgnoreCase); } """); return sb.ToString(); } [Fact] public void DumpFuzzReproCase_ProducesCompilableTestMethod() { var setupOps = new List<FuzzOp> { new FuzzOp.CreateDir(@"sub1\sub3"), new FuzzOp.WriteCsproj("Project0.csproj"), new FuzzOp.WriteFbaFile(@"sub1\sub3\Fba1.cs"), new FuzzOp.WriteOrdinaryCs(@"sub1\Ordinary4.cs"), }; var editOps = new List<FuzzOp> { new FuzzOp.DeleteFile("Project0.csproj"), new FuzzOp.RenameFile(@"sub1\sub3\Fba1.cs", @"sub1\sub3\NewFba64.cs"), }; var reproMethod = BuildFuzzReproCase(iteration: 0, setupOps, editOps); var source = $$""" using System; using System.IO; using System.Linq; using System.Collections.Generic; using System.Threading.Tasks; using Microsoft.CodeAnalysis.Host; using Microsoft.CodeAnalysis.LanguageServer.FileBasedPrograms; using Microsoft.CodeAnalysis.LanguageServer.HostWorkspace; using Microsoft.CodeAnalysis.Test.Utilities; using Microsoft.CodeAnalysis.FileBasedPrograms; using Roslyn.Test.Utilities; using static Roslyn.Test.Utilities.AbstractLanguageServerProtocolTests; using Xunit; internal sealed class Skeleton { private readonly TempRoot {{nameof(_tempRoot)}} = new(); private const string {{nameof(FbaContent)}} = "", {{nameof(OrdinaryCsContent)}} = "", {{nameof(CsprojContent)}} = ""; private Task<TestLspServer> {{nameof(CreateDiscoveryTestServerAsync)}}(string p) => throw null!; {{reproMethod}} } """; var tree = CSharp.CSharpSyntaxTree.ParseText(SourceText.From(source)); IEnumerable<Assembly> assemblies = [ typeof(Assert).Assembly, // xunit.assert typeof(FactAttribute).Assembly, // xunit.core typeof(AssertEx).Assembly, // Microsoft.CodeAnalysis.Test.Utilities typeof(AbstractLanguageServerProtocolTests).Assembly, typeof(Workspace).Assembly, // Microsoft.CodeAnalysis.Workspaces typeof(IHostWorkspaceProvider).Assembly, // Microsoft.CodeAnalysis.LanguageServer.Protocol.Test.Utilities typeof(FileBasedProgramsEntryPointDiscovery).Assembly, // Microsoft.CodeAnalysis.LanguageServer GetType().Assembly, // Microsoft.CodeAnalysis.LanguageServer.UnitTests ]; List<MetadataReference> references = [ .. TargetFrameworkUtil.GetReferences(TargetFramework.Net100), .. assemblies.Select(a => MetadataReference.CreateFromFile(a.Location)), ]; var compilation = CSharp.CSharpCompilation.Create( GetType().Assembly.GetName().Name!, [tree], references, new CSharp.CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); var errors = compilation.GetDiagnostics() .Where(d => d.Severity == DiagnosticSeverity.Error) .Where(d => d.Id != "CS0281") // ignore IVT errors .Select(d => $"{d.Id}: {d.GetMessage()}"); AssertEx.Empty(errors, "Generated fuzz repro case does not compile."); } #endregion }