diff --git a/src/Workspaces/Core/MSBuild.BuildHost/Build/ProjectBuildManager.cs b/src/Workspaces/Core/MSBuild.BuildHost/Build/ProjectBuildManager.cs index 4031bcdc9b3ec..9a331882f074f 100644 --- a/src/Workspaces/Core/MSBuild.BuildHost/Build/ProjectBuildManager.cs +++ b/src/Workspaces/Core/MSBuild.BuildHost/Build/ProjectBuildManager.cs @@ -226,20 +226,21 @@ public void EndBatchBuild() { Debug.Assert(BatchBuildStarted); - var targets = new[] { TargetNames.Compile, TargetNames.CoreCompile }; + var requiredTargets = new[] { TargetNames.Compile, TargetNames.CoreCompile }; + var optionalTargets = new[] { TargetNames.DesignTimeMarkupCompilation }; - return BuildProjectAsync(project, targets, log, cancellationToken); + return BuildProjectAsync(project, requiredTargets, optionalTargets, log, cancellationToken); } private async Task BuildProjectAsync( - MSB.Evaluation.Project project, string[] targets, DiagnosticLog log, CancellationToken cancellationToken) + MSB.Evaluation.Project project, string[] requiredTargets, string[] optionalTargets, DiagnosticLog log, CancellationToken cancellationToken) { // create a project instance to be executed by build engine. // The executed project will hold the final model of the project after execution via msbuild. var projectInstance = project.CreateProjectInstance(); // Verify targets - foreach (var target in targets) + foreach (var target in requiredTargets) { if (!projectInstance.Targets.ContainsKey(target)) { @@ -248,9 +249,18 @@ public void EndBatchBuild() } } + var targets = new List(requiredTargets); + foreach (var target in optionalTargets) + { + if (projectInstance.Targets.ContainsKey(target)) + { + targets.Add(target); + } + } + _batchBuildLogger?.SetProjectAndLog(projectInstance.FullPath, log); - var buildRequestData = new MSB.Execution.BuildRequestData(projectInstance, targets); + var buildRequestData = new MSB.Execution.BuildRequestData(projectInstance, targets.ToArray()); var result = await BuildAsync(buildRequestData, cancellationToken).ConfigureAwait(false); diff --git a/src/Workspaces/Core/MSBuild.BuildHost/MSBuild/Constants/TargetNames.cs b/src/Workspaces/Core/MSBuild.BuildHost/MSBuild/Constants/TargetNames.cs index 576c6d008fb96..34558d25c9a25 100644 --- a/src/Workspaces/Core/MSBuild.BuildHost/MSBuild/Constants/TargetNames.cs +++ b/src/Workspaces/Core/MSBuild.BuildHost/MSBuild/Constants/TargetNames.cs @@ -8,5 +8,6 @@ internal static class TargetNames { public const string Compile = nameof(Compile); public const string CoreCompile = nameof(CoreCompile); + public const string DesignTimeMarkupCompilation = nameof(DesignTimeMarkupCompilation); } } diff --git a/src/Workspaces/MSBuildTest/NewlyCreatedProjectsFromDotNetNew.cs b/src/Workspaces/MSBuildTest/NewlyCreatedProjectsFromDotNetNew.cs new file mode 100644 index 0000000000000..8291fcf8ef9d6 --- /dev/null +++ b/src/Workspaces/MSBuildTest/NewlyCreatedProjectsFromDotNetNew.cs @@ -0,0 +1,284 @@ +// 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: + // \artifacts\bin\Microsoft.CodeAnalysis.Workspaces.MSBuild.UnitTests\\\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))] + [MemberData(nameof(GetCSharpProjectTemplateNames), DisableDiscoveryEnumeration = false)] + public async Task ValidateCSharpTemplateProjects(string templateName) + { + var ignoredDiagnostics = templateName switch + { + "blazor" or "blazorwasm" or "blazorwasm-empty" => + [ + // The type or namespace name {'csharp_blazor_project'|'App'} could not be found + // (are you missing a using directive or an assembly reference?) + // Bug: https://github.com/dotnet/roslyn/issues/72015 + "CS0246", + ], + _ => Array.Empty(), + }; + + await AssertTemplateProjectLoadsCleanlyAsync(templateName, LanguageNames.CSharp, ignoredDiagnostics); + } + + [ConditionalTheory(typeof(DotNetSdkMSBuildInstalled))] + [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(); + + await AssertTemplateProjectLoadsCleanlyAsync(templateName, LanguageNames.VisualBasic, ignoredDiagnostics); + } + + public static TheoryData GetCSharpProjectTemplateNames() + => GetProjectTemplateNames("c#"); + + public static TheoryData GetVisualBasicProjectTemplateNames() + => GetProjectTemplateNames("vb"); + + public static TheoryData 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(new[] { "\r", "\n" }, StringSplitOptions.RemoveEmptyEntries); + + TheoryData templateNames = []; + var foundDivider = false; + + foreach (var line in lines) + { + if (!foundDivider) + { + if (line.StartsWith("----")) + { + foundDivider = true; + } + continue; + } + + var columns = line.Split(new[] { " " }, 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 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; + } + } +}