diff --git a/apps/Build.Abstractions/BuildContext.cs b/apps/Build.Abstractions/BuildContext.cs
index 96c642e..2a762a0 100644
--- a/apps/Build.Abstractions/BuildContext.cs
+++ b/apps/Build.Abstractions/BuildContext.cs
@@ -88,6 +88,13 @@ public BuildContext(ICakeContext context)
///
public MinVerVersion? Version { get; set; }
+ ///
+ /// Gets or sets whether the solution was compiled in this run.
+ /// When true, later test, pack, and publish steps can reuse outputs
+ /// without rebuilding project references.
+ ///
+ public bool SolutionBuilt { get; set; }
+
static (DirectoryPath, FilePath) GetRootPath(ICakeContext context, DirectoryPath path)
{
// Try and find the .git directory.
diff --git a/apps/Build.Abstractions/BuildHelpers.cs b/apps/Build.Abstractions/BuildHelpers.cs
index 04b6776..c4e6ea8 100644
--- a/apps/Build.Abstractions/BuildHelpers.cs
+++ b/apps/Build.Abstractions/BuildHelpers.cs
@@ -12,12 +12,11 @@ namespace Build
using Cake.Common.IO;
using Cake.Common.Tools.DotNet;
using Cake.Common.Tools.DotNet.Build;
+ using Cake.Common.Tools.DotNet.MSBuild;
using Cake.Common.Tools.DotNet.Publish;
- using Cake.Common.Tools.DotNet.Test;
using Cake.Common.Tools.MSBuild;
using Cake.Common.Tools.NuGet;
using Cake.Common.Tools.NuGet.Restore;
- using Cake.Common.Tools.VSTest;
using Cake.Common.Xml;
using Cake.Core;
using Cake.Core.IO;
@@ -38,35 +37,44 @@ public static class BuildHelpers
};
///
- /// Builds the target project.
+ /// Builds or publishes the target project.
+ /// When is set and the project has already been
+ /// built (for example by a solution-level compile), publish reuses those
+ /// outputs instead of rebuilding project references.
///
/// The build context.
/// The project to build.
/// States whether to publish the output.
/// The output path for publishing.
+ /// Skip restore when outputs are already restored.
+ /// Skip building project references.
public static void BuildProject(
this BuildContext context,
BuildProject project,
bool publish = false,
- DirectoryPath? outputPath = default)
+ DirectoryPath? outputPath = default,
+ bool noRestore = false,
+ bool noDependencies = false)
{
context = context ?? throw new ArgumentNullException(nameof(context));
project = project ?? throw new ArgumentNullException(nameof(project));
+ bool reuseOutputs = project.HasBuilt || noDependencies;
+
if (project.BuildEngine == BuildEngine.MSBuild)
{
- // MA - Force a NuGet restore
- context.NuGetRestore(project.ProjectFilePath, new NuGetRestoreSettings());
+ if (!noRestore)
+ {
+ context.NuGetRestore(project.ProjectFilePath, new NuGetRestoreSettings());
+ }
var settings = new MSBuildSettings()
{
Configuration = context.Configuration,
+ MaxCpuCount = 0,
ArgumentCustomization = args =>
{
- if (context.Version is not null)
- {
- args.Append($"/p:SemVer={context.Version}");
- }
+ SolutionHelpers.ApplyCommonProperties(args, context, noDependencies || reuseOutputs);
if (publish && outputPath is not null)
{
@@ -80,11 +88,6 @@ public static void BuildProject(
}
}
- if (context.SolutionPath is not null)
- {
- args.Append($"/p:SolutionDir={context.SolutionPath.FullPath}");
- }
-
return args;
}
};
@@ -96,60 +99,59 @@ public static void BuildProject(
context.MSBuild(project.ProjectFilePath.FullPath, settings);
}
- else
+ else if (publish && outputPath is not null)
{
foreach (string targetFramework in project.TargetFrameworks)
{
- if (publish && outputPath is not null)
- {
- var settings = new DotNetPublishSettings()
- {
- Configuration = context.Configuration,
- ArgumentCustomization = args =>
- {
- if (context.Version is not null)
- {
- args.Append($"/p:SemVer={context.Version}");
- }
-
- if (context.SolutionPath is not null)
- {
- args.Append($"/p:SolutionDir={context.SolutionPath.FullPath}");
- }
-
- return args;
- },
- Framework = targetFramework
- };
-
- context.DotNetPublish(project.ProjectFilePath.FullPath, settings);
- }
- else
+ var settings = new DotNetPublishSettings()
{
- var settings = new DotNetBuildSettings()
- {
- Configuration = context.Configuration,
- ArgumentCustomization = args =>
- {
- if (context.Version is not null)
- {
- args.Append($"/p:SemVer={context.Version}");
- }
-
- if (context.SolutionPath is not null)
- {
- args.Append($"/p:SolutionDir={context.SolutionPath.FullPath}");
- }
-
- return args;
- },
- Framework = targetFramework
- };
-
- context.DotNetBuild(project.ProjectFilePath.FullPath, settings);
- }
+ Configuration = context.Configuration,
+ Framework = targetFramework,
+ OutputDirectory = outputPath,
+ NoBuild = reuseOutputs,
+ NoRestore = noRestore || reuseOutputs,
+ NoDependencies = noDependencies || reuseOutputs,
+ ArgumentCustomization = args => SolutionHelpers.ApplyCommonProperties(args, context, noDependencies || reuseOutputs)
+ };
+
+ context.DotNetPublish(project.ProjectFilePath.FullPath, settings);
}
}
+ else
+ {
+ var settings = new DotNetBuildSettings()
+ {
+ Configuration = context.Configuration,
+ NoRestore = noRestore,
+ NoDependencies = noDependencies,
+ MSBuildSettings = new DotNetMSBuildSettings
+ {
+ MaxCpuCount = 0
+ },
+ ArgumentCustomization = args => SolutionHelpers.ApplyCommonProperties(args, context, noDependencies)
+ };
+
+ context.DotNetBuild(project.ProjectFilePath.FullPath, settings);
+ }
+ }
+
+ ///
+ /// Gets discovered projects in the conventional build order:
+ /// libraries, applications, tests, data, then content.
+ ///
+ /// The build context.
+ /// The ordered project list.
+ public static IReadOnlyList GetProjectsInBuildOrder(this BuildContext context)
+ {
+ context = context ?? throw new ArgumentNullException(nameof(context));
+
+ var projects = new List();
+ projects.AddRange(context.Projects[BuildType.Library]);
+ projects.AddRange(context.Projects[BuildType.Application]);
+ projects.AddRange(context.Projects[BuildType.Test]);
+ projects.AddRange(context.Projects[BuildType.Data]);
+ projects.AddRange(context.Projects[BuildType.Content]);
+ return projects;
}
///
diff --git a/apps/Build.Abstractions/BuildServices.cs b/apps/Build.Abstractions/BuildServices.cs
index 42b1984..ebc309b 100644
--- a/apps/Build.Abstractions/BuildServices.cs
+++ b/apps/Build.Abstractions/BuildServices.cs
@@ -4,7 +4,9 @@
namespace Build
{
+ using System;
using System.Collections.Generic;
+ using System.Linq;
using Microsoft.Extensions.DependencyInjection;
@@ -13,26 +15,26 @@ namespace Build
///
public class BuildServices
{
- readonly IServiceScopeFactory _serviceScopeFactory;
+ readonly IServiceProvider _services;
///
/// Initialises a new instance of
///
- /// The service scope factory.
- public BuildServices(IServiceScopeFactory serviceScopeFactory)
+ /// The root service provider.
+ public BuildServices(IServiceProvider services)
{
- _serviceScopeFactory = serviceScopeFactory;
+ _services = services;
}
///
- /// Gets the avilable hooks.
+ /// Gets the available hooks of the requested type.
+ /// Hooks are discovered from assemblies that reference this build
+ /// system and registered as implementations.
///
/// The hook type.
/// The set of hooks.
public IEnumerable GetHooks()
where THook : ITaskHook
- {
- yield break;
- }
+ => _services.GetServices().OfType();
}
}
diff --git a/apps/Build.Abstractions/Hooks/IBuildHook.cs b/apps/Build.Abstractions/Hooks/IBuildHook.cs
index d1d06e5..6fa2d8c 100644
--- a/apps/Build.Abstractions/Hooks/IBuildHook.cs
+++ b/apps/Build.Abstractions/Hooks/IBuildHook.cs
@@ -8,18 +8,23 @@ namespace Build
///
/// Defines the required contract for implementing a build hook.
+ /// BeforeBuild runs for every project before the solution-level compile so
+ /// extensions can generate sources. AfterBuild runs after compile outputs
+ /// exist (including leftover projects not in the solution).
///
public interface IBuildHook : ITaskHook
{
///
- /// Executes before the build starts.
+ /// Executes before the build starts for the given project.
+ /// Invoked before the solution-level compile.
///
/// The build context.
/// The build project.
void BeforeBuild(BuildContext context, BuildProject project);
///
- /// Executes after the build completes.
+ /// Executes after the build completes for the given project.
+ /// Invoked after the solution-level compile and any leftover project builds.
///
/// The build context.
/// The build project.
@@ -28,18 +33,22 @@ public interface IBuildHook : ITaskHook
///
/// Defines the required contract for implementing an asynchronous build hook.
+ /// Hook phasing matches : all BeforeBuild callbacks
+ /// run before compile, all AfterBuild callbacks run after outputs exist.
///
public interface IAsyncBuildHook : ITaskHook
{
///
- /// Executes before the build starts.
+ /// Executes before the build starts for the given project.
+ /// Invoked before the solution-level compile.
///
/// The build context.
/// The build project.
Task BeforeBuildAsync(BuildContext context, BuildProject project);
///
- /// Executes after the build completes.
+ /// Executes after the build completes for the given project.
+ /// Invoked after the solution-level compile and any leftover project builds.
///
/// The build context.
/// The build project.
diff --git a/apps/Build.Abstractions/PackHelpers.cs b/apps/Build.Abstractions/PackHelpers.cs
index 41b20fb..00f4044 100644
--- a/apps/Build.Abstractions/PackHelpers.cs
+++ b/apps/Build.Abstractions/PackHelpers.cs
@@ -61,7 +61,8 @@ public static void PackProject(
},
Configuration = context.Configuration,
OutputDirectory = context.ArtefactsPath,
- NoBuild = project.HasBuilt
+ NoBuild = project.HasBuilt,
+ NoRestore = project.HasBuilt
});
}
}
diff --git a/apps/Build.Abstractions/SolutionHelpers.cs b/apps/Build.Abstractions/SolutionHelpers.cs
new file mode 100644
index 0000000..a40f531
--- /dev/null
+++ b/apps/Build.Abstractions/SolutionHelpers.cs
@@ -0,0 +1,137 @@
+// Copyright (c) 2021 Ingenium Software Engineering. All rights reserved.
+// This work is licensed under the terms of the MIT license.
+// For a copy, see .
+
+namespace Build
+{
+ using System;
+ using System.Collections.Generic;
+ using System.IO;
+ using System.Text.RegularExpressions;
+
+ using Cake.Common.Diagnostics;
+ using Cake.Common.Tools.DotNet;
+ using Cake.Common.Tools.DotNet.Build;
+ using Cake.Common.Tools.DotNet.MSBuild;
+ using Cake.Core;
+ using Cake.Core.IO;
+
+ using IOPath = System.IO.Path;
+
+ ///
+ /// Provides helpers for solution-level builds.
+ ///
+ public static class SolutionHelpers
+ {
+ static readonly Regex SolutionProjectLine = new Regex(
+ @"^Project\(""[^""]+""\)\s*=\s*""[^""]+"",\s*""([^""]+\.(?:csproj|vbproj|fsproj|sqlproj))""",
+ RegexOptions.IgnoreCase | RegexOptions.Compiled);
+
+ ///
+ /// Builds the resolved solution with parallel MSBuild, compiling each
+ /// project once and building independent projects concurrently.
+ ///
+ /// The build context.
+ ///
+ /// true when a solution file was found and built; otherwise false.
+ ///
+ public static bool TryBuildSolution(this BuildContext context)
+ {
+ context = context ?? throw new ArgumentNullException(nameof(context));
+
+ if (context.SolutionPath is null || !context.FileSystem.Exist(context.SolutionPath))
+ {
+ context.Information("No solution file was resolved; falling back to per-project builds.");
+ return false;
+ }
+
+ context.Information("Building solution {0} (parallel MSBuild)", context.SolutionPath.FullPath);
+
+ var settings = new DotNetBuildSettings
+ {
+ Configuration = context.Configuration,
+ MSBuildSettings = new DotNetMSBuildSettings
+ {
+ MaxCpuCount = 0
+ },
+ ArgumentCustomization = args => ApplyCommonProperties(args, context)
+ };
+
+ context.DotNetBuild(context.SolutionPath.FullPath, settings);
+ context.SolutionBuilt = true;
+ return true;
+ }
+
+ ///
+ /// Determines whether the project is included in the resolved solution.
+ ///
+ /// The build context.
+ /// The project.
+ /// true when the project is listed in the solution.
+ public static bool IsProjectInSolution(this BuildContext context, BuildProject project)
+ {
+ context = context ?? throw new ArgumentNullException(nameof(context));
+ project = project ?? throw new ArgumentNullException(nameof(project));
+
+ return GetSolutionProjectPaths(context).Contains(NormalizePath(project.ProjectFilePath.FullPath));
+ }
+
+ ///
+ /// Gets the set of project file paths listed in the resolved solution.
+ ///
+ /// The build context.
+ /// Normalized full paths of projects in the solution.
+ public static IReadOnlyCollection GetSolutionProjectPaths(this BuildContext context)
+ {
+ context = context ?? throw new ArgumentNullException(nameof(context));
+
+ var paths = new HashSet(StringComparer.OrdinalIgnoreCase);
+ if (context.SolutionPath is null || !context.FileSystem.Exist(context.SolutionPath))
+ {
+ return paths;
+ }
+
+ var solutionDirectory = context.SolutionPath.GetDirectory();
+ foreach (string line in File.ReadAllLines(context.SolutionPath.FullPath))
+ {
+ var match = SolutionProjectLine.Match(line);
+ if (!match.Success)
+ {
+ continue;
+ }
+
+ string relativePath = match.Groups[1].Value.Replace('\\', '/');
+ var projectPath = solutionDirectory.CombineWithFilePath(new FilePath(relativePath));
+ paths.Add(NormalizePath(projectPath.FullPath));
+ }
+
+ return paths;
+ }
+
+ internal static ProcessArgumentBuilder ApplyCommonProperties(
+ ProcessArgumentBuilder args,
+ BuildContext context,
+ bool noDependencies = false)
+ {
+ if (context.Version is not null)
+ {
+ args.Append($"/p:SemVer={context.Version}");
+ }
+
+ if (context.SolutionPath is not null)
+ {
+ args.Append($"/p:SolutionDir={context.SolutionPath.FullPath}");
+ }
+
+ if (noDependencies)
+ {
+ args.Append("/p:BuildProjectReferences=false");
+ }
+
+ return args;
+ }
+
+ static string NormalizePath(string path)
+ => IOPath.GetFullPath(path).TrimEnd(IOPath.DirectorySeparatorChar, IOPath.AltDirectorySeparatorChar);
+ }
+}
diff --git a/apps/Build.Abstractions/Tasks/BuildProjects.cs b/apps/Build.Abstractions/Tasks/BuildProjects.cs
index 3f6c1ac..a35d821 100644
--- a/apps/Build.Abstractions/Tasks/BuildProjects.cs
+++ b/apps/Build.Abstractions/Tasks/BuildProjects.cs
@@ -5,49 +5,98 @@
namespace Build.Tasks
{
using System.Collections.Generic;
+ using System.Linq;
+ using System.Threading.Tasks;
+ using Cake.Common.Diagnostics;
using Cake.Frosting;
///
/// Performs a build of available projects.
+ /// Prefers a solution-level compile so MSBuild can parallelize independent
+ /// projects and build each project once. Per-project hooks still run:
+ /// all /
+ /// callbacks execute first (so extensions can generate sources), then the
+ /// solution is built, then leftover projects not in the solution are built
+ /// without project-reference rebuilds, then AfterBuild hooks run.
///
[TaskName("Build")]
[IsDependentOn(typeof(ResolveVersion))]
[IsDependentOn(typeof(CleanArtefacts))]
- public class BuildProjects : BuildTask
+ public class BuildProjects : AsyncBuildTask
{
public BuildProjects(BuildServices services) : base(services) { }
///
- protected override void RunCore(BuildContext context)
+ protected override async Task RunCoreAsync(BuildContext context)
{
- void BuildAvailableProjects(IEnumerable projects)
- {
- var hooks = Services.GetHooks();
+ var projects = context.GetProjectsInBuildOrder();
+ var syncHooks = Services.GetHooks().ToList();
+ var asyncHooks = Services.GetHooks().ToList();
+
+ await RunBeforeBuildHooksAsync(context, projects, syncHooks, asyncHooks);
+
+ bool solutionBuilt = context.TryBuildSolution();
- foreach (var project in projects)
+ foreach (var project in projects)
+ {
+ if (!solutionBuilt || !context.IsProjectInSolution(project))
{
- foreach (var hook in hooks)
- {
- hook.BeforeBuild(context, project);
- }
+ context.Information(
+ "Building {0} {1}",
+ project.BuildType,
+ project.Name);
+
+ context.BuildProject(
+ project,
+ noRestore: solutionBuilt,
+ noDependencies: solutionBuilt);
+ }
- context.BuildProject(project);
+ project.MarkAsBuilt();
+ }
- foreach (var hook in hooks)
- {
- hook.AfterBuild(context, project);
- }
+ await RunAfterBuildHooksAsync(context, projects, syncHooks, asyncHooks);
+ }
- project.MarkAsBuilt();
+ static async Task RunBeforeBuildHooksAsync(
+ BuildContext context,
+ IReadOnlyList projects,
+ IReadOnlyCollection syncHooks,
+ IReadOnlyCollection asyncHooks)
+ {
+ foreach (var project in projects)
+ {
+ foreach (var hook in syncHooks)
+ {
+ hook.BeforeBuild(context, project);
+ }
+
+ foreach (var hook in asyncHooks)
+ {
+ await hook.BeforeBuildAsync(context, project);
}
}
+ }
- BuildAvailableProjects(context.Projects[BuildType.Library]);
- BuildAvailableProjects(context.Projects[BuildType.Application]);
- BuildAvailableProjects(context.Projects[BuildType.Test]);
- BuildAvailableProjects(context.Projects[BuildType.Data]);
- BuildAvailableProjects(context.Projects[BuildType.Content]);
+ static async Task RunAfterBuildHooksAsync(
+ BuildContext context,
+ IReadOnlyList projects,
+ IReadOnlyCollection syncHooks,
+ IReadOnlyCollection asyncHooks)
+ {
+ foreach (var project in projects)
+ {
+ foreach (var hook in syncHooks)
+ {
+ hook.AfterBuild(context, project);
+ }
+
+ foreach (var hook in asyncHooks)
+ {
+ await hook.AfterBuildAsync(context, project);
+ }
+ }
}
}
}
diff --git a/apps/Build.Abstractions/Tasks/PackProjects.cs b/apps/Build.Abstractions/Tasks/PackProjects.cs
index 4aa6fec..7c78659 100644
--- a/apps/Build.Abstractions/Tasks/PackProjects.cs
+++ b/apps/Build.Abstractions/Tasks/PackProjects.cs
@@ -8,8 +8,10 @@ namespace Build.Tasks
///
/// Packs any available apps and libraries.
+ /// Depends on Build so pack can pass --no-build and reuse outputs.
///
[TaskName("Pack")]
+ [IsDependentOn(typeof(BuildProjects))]
public class PackProjects : BuildTask
{
public PackProjects(BuildServices services) : base(services) { }
diff --git a/apps/Build.Abstractions/Tasks/TestProjects.cs b/apps/Build.Abstractions/Tasks/TestProjects.cs
index 03a0346..9be8100 100644
--- a/apps/Build.Abstractions/Tasks/TestProjects.cs
+++ b/apps/Build.Abstractions/Tasks/TestProjects.cs
@@ -4,12 +4,18 @@
namespace Build.Tasks
{
+ using System.Linq;
+
using Cake.Frosting;
///
/// Executes any unit tests for available test projects.
+ /// After a solution-level build, SDK test projects in the solution are
+ /// run with dotnet test on the solution and --no-build.
+ /// Projects outside the solution or using MSBuild are still tested individually.
///
[TaskName("Test")]
+ [IsDependentOn(typeof(BuildProjects))]
public class TestProjects : BuildTask
{
public TestProjects(BuildServices services) : base(services) { }
@@ -17,8 +23,35 @@ public TestProjects(BuildServices services) : base(services) { }
///
protected override void RunCore(BuildContext context)
{
- foreach (var project in context.Projects[BuildType.Test])
+ var tests = context.Projects[BuildType.Test];
+ if (tests.Count == 0)
{
+ return;
+ }
+
+ bool solutionTested = false;
+ if (context.SolutionBuilt)
+ {
+ bool hasSdkTestsInSolution = tests.Any(project =>
+ project.BuildEngine == BuildEngine.DotNetSdk &&
+ context.IsProjectInSolution(project));
+
+ if (hasSdkTestsInSolution)
+ {
+ context.TestSolution();
+ solutionTested = true;
+ }
+ }
+
+ foreach (var project in tests)
+ {
+ if (solutionTested &&
+ project.BuildEngine == BuildEngine.DotNetSdk &&
+ context.IsProjectInSolution(project))
+ {
+ continue;
+ }
+
context.TestProject(project);
}
}
diff --git a/apps/Build.Abstractions/TestHelpers.cs b/apps/Build.Abstractions/TestHelpers.cs
index bfa682d..2e14d48 100644
--- a/apps/Build.Abstractions/TestHelpers.cs
+++ b/apps/Build.Abstractions/TestHelpers.cs
@@ -21,6 +21,40 @@ namespace Build
///
public static class TestHelpers
{
+ ///
+ /// Runs tests for every SDK test project in the solution without rebuilding.
+ ///
+ /// The build context.
+ public static void TestSolution(this BuildContext context)
+ {
+ string coverletRunSettingsFile = GetCoverletRunSettingsPath(context);
+
+ var settings = new DotNetTestSettings
+ {
+ NoBuild = true,
+ NoRestore = true,
+ Configuration = context.Configuration,
+ ResultsDirectory = context.TestResultsPath,
+ Loggers = new[]
+ {
+ "trx"
+ },
+ ArgumentCustomization = args =>
+ {
+ args.Append("--collect:\"XPlat Code Coverage\"");
+ args.Append($"--settings {coverletRunSettingsFile}");
+ if (context.SolutionPath is not null)
+ {
+ args.Append($"/p:SolutionDir={context.SolutionPath.FullPath}");
+ }
+
+ return args;
+ }
+ };
+
+ context.DotNetTest(context.SolutionPath.FullPath, settings);
+ }
+
///
/// Tests the target project.
///
@@ -32,12 +66,14 @@ public static void TestProject(
{
string name = project.ProjectFilePath.GetFilenameWithoutExtension().Segments.Last();
string resultsFile = $"{name}.xml";
- string coverletFile = $"{name}-coverage";
- string coverletRunSettingsFile = FilePath.FromString("./coverlet.runsettings").MakeAbsolute(context.BuildPath).FullPath;
+ string coverletRunSettingsFile = GetCoverletRunSettingsPath(context);
if (project.BuildEngine == BuildEngine.MSBuild)
{
- context.BuildProject(project);
+ if (!project.HasBuilt)
+ {
+ context.BuildProject(project);
+ }
resultsFile = FilePath.FromString($"./{resultsFile}").MakeAbsolute(context.TestResultsPath).FullPath;
@@ -55,11 +91,11 @@ public static void TestProject(
foreach (string framework in project.TargetFrameworks)
{
resultsFile = $"{name}-{framework}.xml";
- coverletFile = $"{name}-{framework}-coverage";
var settings = new DotNetTestSettings
{
NoBuild = project.HasBuilt,
+ NoRestore = project.HasBuilt,
Configuration = context.Configuration,
Framework = framework,
ResultsDirectory = context.TestResultsPath,
@@ -71,7 +107,11 @@ public static void TestProject(
{
args.Append("--collect:\"XPlat Code Coverage\"");
args.Append($"--settings {coverletRunSettingsFile}");
- args.Append($"/p:SolutionDir={context.SolutionPath.FullPath}");
+ if (context.SolutionPath is not null)
+ {
+ args.Append($"/p:SolutionDir={context.SolutionPath.FullPath}");
+ }
+
return args;
}
};
@@ -81,6 +121,9 @@ public static void TestProject(
}
}
+ static string GetCoverletRunSettingsPath(BuildContext context)
+ => FilePath.FromString("./coverlet.runsettings").MakeAbsolute(context.BuildPath).FullPath;
+
static DirectoryPath? GetTestAdapterLocation(BuildContext context, BuildProject project)
{
if (project.TestFramework == TestFramework.NUnit)