Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<MSB.Execution.ProjectInstance> 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))
{
Expand All @@ -248,9 +249,18 @@ public void EndBatchBuild()
}
}

var targets = new List<string>(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);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
284 changes: 284 additions & 0 deletions src/Workspaces/MSBuildTest/NewlyCreatedProjectsFromDotNetNew.cs
Original file line number Diff line number Diff line change
@@ -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:
// <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))]
[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<string>(),
};

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<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(new[] { "\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(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<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;
}
}
}