File: NewlyCreatedProjectsFromDotNetNew.cs
Web Access
Project: src\src\Workspaces\MSBuildTest\Microsoft.CodeAnalysis.Workspaces.MSBuild.UnitTests.csproj (Microsoft.CodeAnalysis.Workspaces.MSBuild.UnitTests)
// 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;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis.Shared.Extensions;
using Roslyn.Test.Utilities;
using Xunit;
using Xunit.Abstractions;
 
namespace Microsoft.CodeAnalysis.MSBuild.UnitTests
{
    public class NewlyCreatedProjectsFromDotNetNew : MSBuildWorkspaceTestBase
    {
        // When running on Helix the machine will only have the expected SDK
        // installed. However, when running on developer machines there could
        // be any number of SDKs installed. We will locate the Roslyn global.json
        // and use it to ensure our tests are run with the proper SDK.
        private static readonly string? s_globalJsonPath;
 
        // The Maui templates require additional dotnet workloads to be installed.
        // Running `dotnet workload restore` will install workloads but may require
        // admin permissions. Additionally, a restart may be required after workload
        // installation.
        private const bool ExcludeMauiTemplates = true;
 
        protected ITestOutputHelper TestOutputHelper { get; }
 
        static NewlyCreatedProjectsFromDotNetNew()
        {
            // When running on developer machines we will try and use the same global.json
            // as we use for our own build.
            var globalJsonPath = Path.Combine(GetSolutionFolder(), "global.json");
 
            // When running on Helix we will not locate a global.json file.
            if (File.Exists(globalJsonPath))
            {
                s_globalJsonPath = globalJsonPath;
            }
 
            static string GetSolutionFolder()
            {
                // Expected assembly path:
                //  <solutionFolder>\artifacts\bin\Microsoft.CodeAnalysis.Workspaces.MSBuild.UnitTests\<Configuration>\<TFM>\Microsoft.CodeAnalysis.Workspaces.MSBuild.UnitTests.dll
                var assemblyLocation = typeof(DotNetSdkMSBuildInstalled).Assembly.Location;
                var solutionFolder = Directory.GetParent(assemblyLocation)
                    ?.Parent?.Parent?.Parent?.Parent?.Parent?.FullName;
                Assumes.NotNull(solutionFolder);
                return solutionFolder;
            }
        }
 
        public NewlyCreatedProjectsFromDotNetNew(ITestOutputHelper output)
        {
            TestOutputHelper = output;
        }
 
        [ConditionalTheory(typeof(DotNetSdkMSBuildInstalled), AlwaysSkip = "https://github.com/dotnet/roslyn/issues/74157")]
        [MemberData(nameof(GetCSharpProjectTemplateNames), DisableDiscoveryEnumeration = false)]
        public async Task ValidateCSharpTemplateProjects(string templateName)
        {
            if (templateName == "mstest-playwright")
            {
                // https://github.com/dotnet/test-templates/issues/412
                return;
            }
 
            await AssertTemplateProjectLoadsCleanlyAsync(templateName, LanguageNames.CSharp);
        }
 
        [ConditionalTheory(typeof(DotNetSdkMSBuildInstalled), AlwaysSkip = "https://github.com/dotnet/roslyn/issues/74827")]
        [MemberData(nameof(GetVisualBasicProjectTemplateNames), DisableDiscoveryEnumeration = false)]
        public async Task ValidateVisualBasicTemplateProjects(string templateName)
        {
            var ignoredDiagnostics = !ExecutionConditionUtil.IsWindows
                ? [
                    // Type 'Global.Microsoft.VisualBasic.ApplicationServices.ApplicationBase' is not defined.
                    // Bug: https://github.com/dotnet/roslyn/issues/72014
                    "BC30002",
                ]
                : Array.Empty<string>();
 
            await AssertTemplateProjectLoadsCleanlyAsync(templateName, LanguageNames.VisualBasic, ignoredDiagnostics);
        }
 
        public static TheoryData<string> GetCSharpProjectTemplateNames()
            => GetProjectTemplateNames("c#");
 
        public static TheoryData<string> GetVisualBasicProjectTemplateNames()
            => GetProjectTemplateNames("vb");
 
        public static TheoryData<string> GetProjectTemplateNames(string language)
        {
            // The expected output from the list command is as follows.
 
            // These templates matched your input: --language='vb', --type='project'
            //
            // Template Name                  Short Name           Language  Tags
            // -----------------------------  -------------------  --------  ---------------
            // Class Library                  classlib             C#,F#,VB  Common/Library
            // Console App                    console              C#,F#,VB  Common/Console
            // ...
 
            var result = RunDotNet($"new list --type project --language {language}", output: null);
 
            var lines = result.Output.Split(["\r", "\n"], StringSplitOptions.RemoveEmptyEntries);
 
            TheoryData<string> templateNames = [];
            var foundDivider = false;
 
            foreach (var line in lines)
            {
                if (!foundDivider)
                {
                    if (line.StartsWith("----"))
                    {
                        foundDivider = true;
                    }
                    continue;
                }
 
                var columns = line.Split(["  "], StringSplitOptions.RemoveEmptyEntries)
                    .Select(c => c.Trim())
                    .ToArray();
 
                // Some templates may list multiple short names for the same template. It
                // will suffice to take the first short name.
                var templateShortName = columns[1].Split(',').First();
 
                if (ExcludeMauiTemplates && templateShortName.StartsWith("maui"))
                    continue;
 
                templateNames.Add(templateShortName);
            }
 
            Assert.True(foundDivider);
 
            return templateNames;
        }
 
        private async Task AssertTemplateProjectLoadsCleanlyAsync(string templateName, string languageName, string[]? ignoredDiagnostics = null)
        {
            if (ignoredDiagnostics?.Length > 0)
            {
                TestOutputHelper.WriteLine($"Ignoring compiler diagnostics: \"{string.Join("\", \"", ignoredDiagnostics)}\"");
            }
 
            var projectDirectory = SolutionDirectory.Path;
            var projectFilePath = GetProjectFilePath(projectDirectory, languageName);
 
            CreateNewProject(templateName, projectDirectory, languageName, TestOutputHelper);
 
            await AssertProjectLoadsCleanlyAsync(projectFilePath, ignoredDiagnostics ?? []);
 
            return;
 
            static string GetProjectFilePath(string projectDirectory, string languageName)
            {
                var projectName = new DirectoryInfo(projectDirectory).Name;
                var projectExtension = languageName switch
                {
                    LanguageNames.CSharp => "csproj",
                    LanguageNames.VisualBasic => "vbproj",
                    _ => throw new ArgumentOutOfRangeException(nameof(languageName), actualValue: languageName, message: "Only C# and VB.NET projects are supported.")
                };
                return Path.Combine(projectDirectory, $"{projectName}.{projectExtension}");
            }
 
            static void CreateNewProject(string templateName, string outputDirectory, string languageName, ITestOutputHelper output)
            {
                var language = languageName switch
                {
                    LanguageNames.CSharp => "C#",
                    LanguageNames.VisualBasic => "VB",
                    _ => throw new ArgumentOutOfRangeException(nameof(languageName), actualValue: languageName, message: "Only C# and VB.NET projects are supported.")
                };
 
                TryCopyGlobalJson(outputDirectory);
 
                var newResult = RunDotNet($"new \"{templateName}\" -o \"{outputDirectory}\" --language \"{language}\"", output, outputDirectory);
 
                // Most templates invoke restore as a post-creation action. However, some, like the
                // Maui templates, do not run restore since they require additional workloads to be
                // installed.
                if (newResult.Output.Contains("Restoring"))
                {
                    return;
                }
 
                try
                {
                    // Attempt a restore and see if we are instructed to install additional workloads.
                    var restoreResult = RunDotNet($"restore", output, outputDirectory);
                }
                catch (InvalidOperationException ex) when (ex.Message.Contains("command: dotnet workload restore"))
                {
                    throw new InvalidOperationException($"The '{templateName}' template requires additional dotnet workloads to be installed. It should be excluded during template discovery. " + ex.Message);
                }
            }
 
            static void TryCopyGlobalJson(string outputDirectory)
            {
                // When running in Helix we will not find a global.json to copy.
                if (s_globalJsonPath is null)
                {
                    return;
                }
 
                var tempGlobalJsonPath = Path.Combine(outputDirectory, "global.json");
                File.Copy(s_globalJsonPath, tempGlobalJsonPath);
            }
 
            static async Task AssertProjectLoadsCleanlyAsync(string projectFilePath, string[] ignoredDiagnostics)
            {
                using var workspace = CreateMSBuildWorkspace();
                var project = await workspace.OpenProjectAsync(projectFilePath, cancellationToken: CancellationToken.None);
 
                AssertEx.Empty(workspace.Diagnostics, $"The following workspace diagnostics are being reported for the template.");
 
                var compilation = await project.GetRequiredCompilationAsync(CancellationToken.None);
 
                // Unnecessary using directives are reported with a severity of Hidden
                var nonHiddenDiagnostics = compilation!.GetDiagnostics()
                    .Where(diagnostic => diagnostic.Severity > DiagnosticSeverity.Hidden)
                    .ToImmutableArray();
 
                // For good test hygiene lets ensure that all ignored diagnostics were actually reported.
                var reportedDiagnosticIds = nonHiddenDiagnostics
                    .Select(diagnostic => diagnostic.Id)
                    .ToImmutableHashSet();
                var unnecessaryIgnoreDiagnostics = ignoredDiagnostics
                    .Where(id => !reportedDiagnosticIds.Contains(id));
 
                AssertEx.Empty(unnecessaryIgnoreDiagnostics, $"The following diagnostics are unnecessarily being ignored for the template.");
 
                var filteredDiagnostics = nonHiddenDiagnostics
                    .Where(diagnostic => !ignoredDiagnostics.Contains(diagnostic.Id));
 
                AssertEx.Empty(filteredDiagnostics, $"The following compiler diagnostics are being reported for the template.");
            }
        }
 
        private static ProcessResult RunDotNet(string arguments, ITestOutputHelper? output, string? workingDirectory = null)
        {
            var dotNetExeName = "dotnet" + (Path.DirectorySeparatorChar == '/' ? "" : ".exe");
 
            // Ensure output is in english since we will be parsing values from it.
            Dictionary<string, string> additionalEnvironmentVars = new()
            {
                ["DOTNET_CLI_UI_LANGUAGE"] = "en"
            };
 
            var result = ProcessUtilities.Run(dotNetExeName, arguments, workingDirectory, additionalEnvironmentVars);
 
            if (result.ExitCode != 0)
            {
                throw new InvalidOperationException(string.Join(Environment.NewLine,
                    [
                        $"`dotnet {arguments}` returned a non-zero exit code.",
                        "Output:",
                        result.Output,
                        "Error:",
                        result.Errors
                    ]));
            }
 
            output?.WriteLine(result.Output);
 
            return result;
        }
    }
}