diff --git a/Affected.sln b/Affected.sln index b690ee6..b1dd020 100755 --- a/Affected.sln +++ b/Affected.sln @@ -23,6 +23,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DotnetAffected.Testing.Util EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DotnetAffected.Core.Tests", "test\DotnetAffected.Core.Tests\DotnetAffected.Core.Tests.csproj", "{3F8A1C4F-1A46-4898-91CB-A67AD8DE301C}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DotnetAffected.Tasks", "src\DotnetAffected.Tasks\DotnetAffected.Tasks.csproj", "{4376F798-3215-4CB7-873E-0D7EB090A5B2}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DotnetAffected.Tasks.Tests", "test\DotnetAffected.Tasks.Tests\DotnetAffected.Tasks.Tests.csproj", "{3F078C7E-2584-47E9-BE40-8B3375B06962}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -120,6 +124,30 @@ Global {3F8A1C4F-1A46-4898-91CB-A67AD8DE301C}.Release|x64.Build.0 = Release|Any CPU {3F8A1C4F-1A46-4898-91CB-A67AD8DE301C}.Release|x86.ActiveCfg = Release|Any CPU {3F8A1C4F-1A46-4898-91CB-A67AD8DE301C}.Release|x86.Build.0 = Release|Any CPU + {4376F798-3215-4CB7-873E-0D7EB090A5B2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4376F798-3215-4CB7-873E-0D7EB090A5B2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4376F798-3215-4CB7-873E-0D7EB090A5B2}.Debug|x64.ActiveCfg = Debug|Any CPU + {4376F798-3215-4CB7-873E-0D7EB090A5B2}.Debug|x64.Build.0 = Debug|Any CPU + {4376F798-3215-4CB7-873E-0D7EB090A5B2}.Debug|x86.ActiveCfg = Debug|Any CPU + {4376F798-3215-4CB7-873E-0D7EB090A5B2}.Debug|x86.Build.0 = Debug|Any CPU + {4376F798-3215-4CB7-873E-0D7EB090A5B2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4376F798-3215-4CB7-873E-0D7EB090A5B2}.Release|Any CPU.Build.0 = Release|Any CPU + {4376F798-3215-4CB7-873E-0D7EB090A5B2}.Release|x64.ActiveCfg = Release|Any CPU + {4376F798-3215-4CB7-873E-0D7EB090A5B2}.Release|x64.Build.0 = Release|Any CPU + {4376F798-3215-4CB7-873E-0D7EB090A5B2}.Release|x86.ActiveCfg = Release|Any CPU + {4376F798-3215-4CB7-873E-0D7EB090A5B2}.Release|x86.Build.0 = Release|Any CPU + {3F078C7E-2584-47E9-BE40-8B3375B06962}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3F078C7E-2584-47E9-BE40-8B3375B06962}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3F078C7E-2584-47E9-BE40-8B3375B06962}.Debug|x64.ActiveCfg = Debug|Any CPU + {3F078C7E-2584-47E9-BE40-8B3375B06962}.Debug|x64.Build.0 = Debug|Any CPU + {3F078C7E-2584-47E9-BE40-8B3375B06962}.Debug|x86.ActiveCfg = Debug|Any CPU + {3F078C7E-2584-47E9-BE40-8B3375B06962}.Debug|x86.Build.0 = Debug|Any CPU + {3F078C7E-2584-47E9-BE40-8B3375B06962}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3F078C7E-2584-47E9-BE40-8B3375B06962}.Release|Any CPU.Build.0 = Release|Any CPU + {3F078C7E-2584-47E9-BE40-8B3375B06962}.Release|x64.ActiveCfg = Release|Any CPU + {3F078C7E-2584-47E9-BE40-8B3375B06962}.Release|x64.Build.0 = Release|Any CPU + {3F078C7E-2584-47E9-BE40-8B3375B06962}.Release|x86.ActiveCfg = Release|Any CPU + {3F078C7E-2584-47E9-BE40-8B3375B06962}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {1A7D0E97-544D-4162-8361-1F631D798E76} = {4881D1F3-A668-4615-BC07-60BBD6718A87} @@ -129,5 +157,7 @@ Global {AD4FD7CF-1334-4976-8657-D5B44E599149} = {7D522C9E-5903-4BE4-81D9-866769469A0C} {7B1B393C-5BA3-4078-A609-AE22B46EFD7B} = {DAE457FC-8DA7-4A5E-8327-F429CAED3BD8} {3F8A1C4F-1A46-4898-91CB-A67AD8DE301C} = {DAE457FC-8DA7-4A5E-8327-F429CAED3BD8} + {4376F798-3215-4CB7-873E-0D7EB090A5B2} = {4881D1F3-A668-4615-BC07-60BBD6718A87} + {3F078C7E-2584-47E9-BE40-8B3375B06962} = {DAE457FC-8DA7-4A5E-8327-F429CAED3BD8} EndGlobalSection EndGlobal diff --git a/src/DotnetAffected.Tasks/AffectedTask.cs b/src/DotnetAffected.Tasks/AffectedTask.cs new file mode 100644 index 0000000..0210a4b --- /dev/null +++ b/src/DotnetAffected.Tasks/AffectedTask.cs @@ -0,0 +1,118 @@ +using System; +using System.Linq; +using DotnetAffected.Abstractions; +using DotnetAffected.Core; +using Microsoft.Build.Execution; +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; +using System.Collections; +using System.Collections.Generic; + +namespace DotnetAffected.Tasks +{ + public class AffectedTask : Microsoft.Build.Utilities.Task + { + [Required] + public string Root { get; set; } + + [Required] + public ITaskItem[] Projects { get; set; } + + public ITaskItem[] AssumeChanges { get; set; } + + public ITaskItem[]? FilterClasses { get; set; } + + [Output] + public ITaskItem[] FilterInstances { get; private set; } + + [Output] + public string[] ModifiedProjects { get; private set; } + + [Output] + public int ModifiedProjectsCount { get; private set; } + + public override bool Execute() + { + try + { + var affectedOptions = new AffectedOptions(Root); + + var graph = new ProjectGraphFactory(affectedOptions).BuildProjectGraph(); + IChangesProvider changesProvider = AssumeChanges?.Any() == true + ? new AssumptionChangesProvider(graph, AssumeChanges.Select(c => c.ItemSpec)) + : new GitChangesProvider(); + + var executor = new AffectedExecutor(affectedOptions, + graph, + changesProvider, + new PredictionChangedProjectsProvider(graph, affectedOptions)); + + var results = executor.Execute(); + var modifiedProjectInstances = new HashSet(); + var modifiedProjects = new List(); + var filterInstances = new List(); + var filterTypes = BuildFilterClassMetadata(); + + foreach (var node in results.ProjectsWithChangedFiles.Concat(results.AffectedProjects)) + { + if (modifiedProjectInstances.Add(node.ProjectInstance)) + { + modifiedProjects.Add(node.ProjectInstance.FullPath); + + if (filterTypes.Length > 0) + { + var projectInstance = node.ProjectInstance; + foreach (var filterType in filterTypes) + { + var taskItem = new TaskItem(projectInstance.FullPath); + filterInstances.Add(taskItem); + + foreach (var kvp in filterType) + taskItem.SetMetadata(kvp.Key, projectInstance.GetProperty(kvp.Key)?.EvaluatedValue ?? kvp.Value); + } + } + } + } + + FilterInstances = filterInstances.ToArray(); + ModifiedProjects = modifiedProjects.ToArray(); + ModifiedProjectsCount = ModifiedProjects.Length; + } + catch (Exception? e) + { + while (e is not null) + { + Log.LogErrorFromException(e); + e = e.InnerException; + } + } + + return !Log.HasLoggedErrors; + } + + private Dictionary[] BuildFilterClassMetadata() + { + Dictionary Selector(ITaskItem filter) + { + var t = new Dictionary(); + foreach (var obj in filter.CloneCustomMetadata()) + { + var entry = (DictionaryEntry)obj; + t[(string)entry.Key] = entry.Value as string ?? ""; + } + + t["AffectedFilterClassName"] = filter.ItemSpec; + return t; + } + + return FilterClasses is null + ? Array.Empty>() + : FilterClasses.Select(Selector).ToArray(); + } + + static AffectedTask() + { + Lib2GitNativePathHelper.ResolveCustomNativeLibraryPath(); + } + } +} diff --git a/src/DotnetAffected.Tasks/DotnetAffected.Tasks.csproj b/src/DotnetAffected.Tasks/DotnetAffected.Tasks.csproj new file mode 100644 index 0000000..9aa7363 --- /dev/null +++ b/src/DotnetAffected.Tasks/DotnetAffected.Tasks.csproj @@ -0,0 +1,83 @@ + + + + + + DotnetAffected.Tasks + + true + + true + MSBuildSdk + false + true + + false + true + true + + + + + + + + + + + + + + + + + + + + + + + All + true + runtime + + + All + true + + + + + + + + + $(TargetsForTfmSpecificContentInPackage);_AddBuildOutputToPackageCore + + + + + + + + + + + + + + + diff --git a/src/DotnetAffected.Tasks/Lib2GitNativePathHelper.cs b/src/DotnetAffected.Tasks/Lib2GitNativePathHelper.cs new file mode 100644 index 0000000..d301e05 --- /dev/null +++ b/src/DotnetAffected.Tasks/Lib2GitNativePathHelper.cs @@ -0,0 +1,60 @@ +using System; +using System.IO; +using System.Runtime.InteropServices; + +namespace DotnetAffected.Tasks +{ + public static class Lib2GitNativePathHelper + { + public static void ResolveCustomNativeLibraryPath() + { + var assemblyDirectory = Path.GetDirectoryName(typeof(LibGit2Sharp.GlobalSettings).Assembly.Location); + var runtimesDirectory = Path.Combine(assemblyDirectory ?? "", "runtimes"); + + if (!Directory.Exists(runtimesDirectory)) + return; + + var processorArchitecture = RuntimeInformation.ProcessArchitecture.ToString().ToLowerInvariant(); + + var (os, libExtension) = GetNativeInfo(); + + foreach (var runtimeFolder in Directory.GetDirectories(runtimesDirectory, $"{os}*-{processorArchitecture}")) + { + var libFolder = Path.Combine(runtimeFolder, "native"); + + foreach (var libFilePath in Directory.GetFiles(libFolder, $"*{libExtension}")) + { + if (IsLibraryLoadable(libFilePath)) + { + LibGit2Sharp.GlobalSettings.NativeLibraryPath = libFolder; + return; + } + + } + } + } + + private static (string Os, string LibExtension) GetNativeInfo() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + return ("linux", ".so"); + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + return ("osx", ".dylib"); + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + return ("win", ".dll"); + + throw new PlatformNotSupportedException(); + } + + private static bool IsLibraryLoadable(string libPath) + { + if (File.Exists(libPath) && NativeLibrary.TryLoad(libPath, out var ptr)) + { + NativeLibrary.Free(ptr); + return true; + } + return false; + } + + } +} diff --git a/src/DotnetAffected.Tasks/LibGit2Sharp-Integration.md b/src/DotnetAffected.Tasks/LibGit2Sharp-Integration.md new file mode 100644 index 0000000..c30f99b --- /dev/null +++ b/src/DotnetAffected.Tasks/LibGit2Sharp-Integration.md @@ -0,0 +1,27 @@ + + +`DotnetAffected.Core` uses `LibGit2Sharp` to analyse changes using the git history/log. + +`LibGit2Sharp` uses native, os based, git libraries to perform all git operations. + +Loading of the native libraries is based on predefined locations relative to the application/assembly. +It works fine when used in the context of an application. +With a Task however, it does not. + +A task is loaded in the context of the build running it. +When the task activates `LibGit2Sharp`, it will try to load the native libraries, however in the +context of the build application which does not have access to the native libraries. + +This is known issue when using dependencies in a build library using Tasks. +The solution is to omit dependency declaration and instead physically include them with the +shipped library. + +With `LibGit2Sharp` we also need to tell it where to look for the native libraries. +`LibGit2Sharp`' will load the file from the path we provide, however for osx/linux it will +try to load a file which does not exists since it comes with a `lib` prefix which the +os native library resolver knows how to handle but with custom load it fails. + +To workaround that we just add an additional build step to the library to also have a copy +of the native library without the `lib` prefix. + +So we have 2 files for each implementation, for example: `libgit2-XYZ.so` and `git2-XYZ.so` diff --git a/src/DotnetAffected.Tasks/README.md b/src/DotnetAffected.Tasks/README.md new file mode 100644 index 0000000..42fc878 --- /dev/null +++ b/src/DotnetAffected.Tasks/README.md @@ -0,0 +1,264 @@ +# DotnetAffected.Tasks + +`DotnetAffected.Tasks` is an MSBuild project SDK that allows project tree owners the ability to build projects which have changed or if their have a dependency which have changed. + +In an enterprise-level CI build, you want to have a way to control what projects are built in your hosted build system based +on their modified state. + +`DotnetAffected.Tasks` SDK provides the automated filtering while [Microsoft.Build.Traversal](https://github.com/microsoft/MSBuildSdks/tree/main/src/Traversal) is used +for execution. + +## Example + +1. Ensure `DotnetAffected.Tasks` SDK is registered as an SDK + This can be done in one of: + - Adding it to `global.json` +```json +{ + "msbuild-sdks": { + "DotnetAffected.Tasks" : "3.0.0" + } +} +``` + +or + +```xml + + ... +``` + +3. Create a dedicated `props` file **in the root of the git repo**: + +```xml + + +``` + +> The actual filename is for you to choose, here we set it to `ci.props` + +7. Run the build/test/clean etc... + +```bash +dotnet build ./ci.props +``` + +## Context Filtering + +With `Context Filtering` you can control which projects are references based on evaluated +properties from the references project itself. + +For every project that a was impacted from the change, you can analyze and filter based on the +evaluated properties of the project. + +First you need to define the properties you want to retrieve from each project +so you can evaluate them before running it and optionally filter them out. + +We group a collection of such properties and call it `AffectedFilterClass`. +Each project is then assigned a new instance of `AffectedFilterClass` representing the values in the project. + +```xml + + + + + + + +``` + +`Include="No Backoffice"` (ItemSpec) is used to assign an identity for the group so +we can use it to create smart filtering based on different filter classes. + +Now, for every project, the property `IsBackofficeLibrary` will evaluate and assigned to a new +object, along with other properties defined. + +> If a defined property does not exist in the project, the value in the class is used. +> I.E you can apply default values! +> Note that only for properties that **DOES NOT EXISTS**, empty values == exists! + +**ci.props** + +```xml + + + + + + + + + + + + + + + + + +``` + +## Extensibility + +You can add/remove projects after the affected projects resolved, ad-hoc in `ci.props`: + +```xml + + + + + + + + + +``` + +Setting the following properties control how `DotnetAffected.Tasks` SDK works. + +| Property | Description | +|-------------------------------------|-------------| +| `CustomBeforeAffectedTargets` | A list of custom MSBuild projects to import **before** `DotnetAffected.Tasks` targets are declared.| +| `CustomAfterAffectedTargets` | A list of custom MSBuild projects to import **after** `DotnetAffected.Tasks` targets are declared.| + +
+ +So instead of using ad-hoc `.targets` code in your `.props` file, you can: + +**ci.props**: + +```xml + + + $(CustomAfterAffectedTargets);$(MSBuildThisFileDirectory)ci.targets + + +``` + +**ci.targets**: + +```xml + + + + + + + +``` + +Note that all [Microsoft.Build.Traversal](https://github.com/microsoft/MSBuildSdks/tree/main/src/Traversal) extensibility options +are also valid! + +## Input / Output API + +| Name | Type | Description | +|-----------------------------|-----------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| DotnetAffectedCheck | Target | Actual target that calculate the affected projects, you can execute a task after this one to post-process projects. | +| UsingDotnetAffectedTasks | Property | Input indicating if the `DotnetAffectedCheck` should run.
If `empty string` or `true` it will run, any other value disables this plugin. | +| DotnetAffectedRoot | Property | Input pointing to the root directory of the project, where `.git` folder is.
**Default**: [MSBuildStartupDirectory](https://learn.microsoft.com/en-us/visualstudio/msbuild/msbuild-reserved-and-well-known-properties?view=vs-2022) | +| DotnetAffectedProjectCount | Property | Output integer stating the amount of projects detected to be modified by `DotnetAffected` | +| CustomBeforeAffectedTargets | Property | A list of custom MSBuild projects to import **before** `DotnetAffected.Tasks` targets are declared | +| CustomAfterAffectedTargets | Property | A list of custom MSBuild projects to import **after** `DotnetAffected.Tasks` targets are declared. | + + +## How can I use these SDKs? + +When using an MSBuild Project SDK obtained via NuGet (such as the SDKs in this repo) a specific version **must** be specified. + +Either append the version to the package name: + +```xml + + ... +``` + +Or omit the version from the SDK attribute and specify it in the version in `global.json`, which can be useful to synchronise versions across multiple projects in a solution: + +```json +{ + "msbuild-sdks": { + "Microsoft.Build.Traversal" : "2.0.12" + } +} +``` + +Since MSBuild 15.6, SDKs are downloaded as NuGet packages automatically. Earlier versions of MSBuild 15 required SDKs to be installed. + +For more information, [read the documentation](https://docs.microsoft.com/visualstudio/msbuild/how-to-use-project-sdk). + +## Troubleshooting + +### Invalid Git repository or workdir + +If your getting the error: + +``` +error : Path '/x/y/z' doesn't point at a valid Git repository or workdir. +``` + +You are probably executing the build from a folder that is not the root of the git repository. + +Either execute it from the root or explicitly set the root in the `.props` file + +**ci.props** + +```xml + + + $(MSBuildThisFileDirectory)..\.. + + +``` + + +### TargetFramework resolution [Error MSB4062] + +If your getting the error: + +``` +error MSB4062: The "DotnetAffected.Tasks.AffectedTask" task could not be loaded from the assembly .... +``` +It is most probably due to invalid `TargetFramework` resolution. + +Project files that use `DotnetAffected.Tasks` or `Microsoft.Build.Traversal` are framework agnostic +as they don't actually build projects, they just delegate the build to a different execution with it's +own configuration. + +However, `DotnetAffected.Tasks` itself contains code to execute in build time, which +support TFMs `netcore3.1`, `net5.0` and `net6.0`. + +The TFM must be known so the proper TFM facing assembly is used. + +In most cases it is automatically resolved using the following logic: + +- The value of the build property `MicrosoftNETBuildTasksTFM` +- The value of the build property `MSBuildVersion` + - If >= `17.0.0` it will resolve to `net6.0` + - Else if >= `16.11.0` it will resolve to `net5.0` + - Else it will resolve to `netcoreapp3.1` + +If you have issues, you can override the logic by specifically setting the ``. + + +```xml + + + net6.0 + + +``` + +> `DotnetAffected.Tasks` provides MSBuild integration using `DotnetAffected.Core` under the hood. +`DotnetAffected.Core` support TFMs `netcore3.1`, `net5.0` and `net6.0`. + diff --git a/src/DotnetAffected.Tasks/Sdk/Sdk.props b/src/DotnetAffected.Tasks/Sdk/Sdk.props new file mode 100644 index 0000000..05fefe3 --- /dev/null +++ b/src/DotnetAffected.Tasks/Sdk/Sdk.props @@ -0,0 +1,29 @@ + + + + + + true + + + + <_DotnetAffectedTargetFramework>$(TargetFramework) + <_DotnetAffectedTargetFramework Condition="'$(_DotnetAffectedTargetFramework)' == ''">$(MicrosoftNETBuildTasksTFM) + <_DotnetAffectedTargetFramework Condition="'$(_DotnetAffectedTargetFramework)' == '' And $([MSBuild]::VersionGreaterThanOrEquals('$(MSBuildVersion)', '17.0.0'))">net6.0 + <_DotnetAffectedTargetFramework Condition="'$(_DotnetAffectedTargetFramework)' == '' And $([MSBuild]::VersionGreaterThanOrEquals('$(MSBuildVersion)', '16.11.0'))">net5.0 + <_DotnetAffectedTargetFramework Condition="'$(_DotnetAffectedTargetFramework)' == ''">netcoreapp3.1 + + $(_DotnetAffectedTargetFramework) + + + + + diff --git a/src/DotnetAffected.Tasks/Sdk/Sdk.targets b/src/DotnetAffected.Tasks/Sdk/Sdk.targets new file mode 100644 index 0000000..bfad913 --- /dev/null +++ b/src/DotnetAffected.Tasks/Sdk/Sdk.targets @@ -0,0 +1,32 @@ + + + + + + <_DotnetAffectedTargetFramework Condition="'$(TargetFramework)' != ''">$(TargetFramework) + <_DotnetAffectedTaskAssembly>$(MSBuildThisFileDirectory)\..\tools\$(_DotnetAffectedTargetFramework)\DotnetAffected.Tasks.dll + + + + + + + + $(MSBuildStartupDirectory) + + + + + + + + + + + + + + + + + diff --git a/src/dotnet-affected/dotnet-affected.csproj b/src/dotnet-affected/dotnet-affected.csproj index c2f1f7c..4ad59d1 100755 --- a/src/dotnet-affected/dotnet-affected.csproj +++ b/src/dotnet-affected/dotnet-affected.csproj @@ -1,6 +1,6 @@  - + Exe @@ -9,15 +9,15 @@ - - - - - - - - - + + + + + + + + + @@ -31,7 +31,7 @@ - + diff --git a/test/DotnetAffected.Tasks.Tests/AffectedDetectionTests.cs b/test/DotnetAffected.Tasks.Tests/AffectedDetectionTests.cs new file mode 100644 index 0000000..94b9425 --- /dev/null +++ b/test/DotnetAffected.Tasks.Tests/AffectedDetectionTests.cs @@ -0,0 +1,105 @@ +using DotnetAffected.Tasks.Tests.Resources; +using DotnetAffected.Testing.Utils; +using System.Linq; +using System.Threading.Tasks; +using Xunit; +using Xunit.Abstractions; + +namespace DotnetAffected.Tasks.Tests +{ + public class AffectedDetectionTests : BaseAffectedTaskBuildTest + { + private readonly ITestOutputHelper _testOutputHelper; + + public AffectedDetectionTests(ITestOutputHelper testOutputHelper) + { + _testOutputHelper = testOutputHelper; + } + + [Fact] + public async Task When_changes_are_made_to_a_project_dependant_projects_should_be_affected() + { + var project1 = Repository.CreateCsProject("project-1"); + var project2 = Repository.CreateCsProject("project-2"); + + await Repository.PrepareTaskInfra(); + + // Commit so there are no changes + Repository.StageAndCommit(); + + await Repository.CreateTextFileAsync(project1, $"file-0.cs", $"// contents 0"); + + ExecuteCommandAndCollectResults(); + + //Assert + Assert.True(ExitSuccess); + Assert.True(HasProjects); + Assert.Single(Projects); + Assert.Equal(project1.FullPath, Projects.Single()); + } + + [Fact] + public async Task When_task_filter_is_defined_only_changes_that_pass_the_filter_should_be_affected() + { + // Test A + + await Repository.PrepareTaskInfra(TestProjectScenarios.AffectedFilterClass); + + var project1 = Repository.CreateCsProject("project-1", p => + { + p.AddProperty("IsClientLibrary", "false"); + }); + var project2 = Repository.CreateCsProject("project-2", p => + { + p.AddProperty("IsClientLibrary", "false"); + }); + + // Commit so there are no changes + Repository.StageAndCommit(); + + await Repository.CreateTextFileAsync(project1, $"file-0.cs", $"// contents 0"); + await Repository.CreateTextFileAsync(project2, $"file-0.cs", $"// contents 0"); + + ExecuteCommandAndCollectResults(); + + Assert.True(ExitSuccess); + Assert.False(HasProjects); + Assert.Empty(Projects); + + // Test B + Repository.StageAndCommit(); + + project2.AddOrUpdateProperty("IsClientLibrary", "true"); + project2.Save(); + + await Repository.CreateTextFileAsync(project1, $"file-1.cs", $"// contents 1"); + await Repository.CreateTextFileAsync(project2, $"file-1.cs", $"// contents 1"); + + ExecuteCommandAndCollectResults(); + + // Commit so there are no changes + Assert.True(ExitSuccess); + Assert.True(HasProjects); + Assert.Single(Projects); + Assert.Equal(project2.FullPath, Projects.Single()); + + // Test C + Repository.StageAndCommit(); + + project1.AddOrUpdateProperty("IsClientLibrary", "true"); + project1.Save(); + + await Repository.CreateTextFileAsync(project1, $"file-2.cs", $"// contents 2"); + await Repository.CreateTextFileAsync(project2, $"file-2.cs", $"// contents 2"); + + ExecuteCommandAndCollectResults(); + + // Commit so there are no changes + Assert.True(ExitSuccess); + Assert.True(HasProjects); + Assert.Equal(2, Projects.Count()); + Assert.Contains(project1.FullPath, Projects); + Assert.Contains(project2.FullPath, Projects); + } + } +} diff --git a/test/DotnetAffected.Tasks.Tests/BaseAffectedTaskBuildTest.cs b/test/DotnetAffected.Tasks.Tests/BaseAffectedTaskBuildTest.cs new file mode 100644 index 0000000..4311db1 --- /dev/null +++ b/test/DotnetAffected.Tasks.Tests/BaseAffectedTaskBuildTest.cs @@ -0,0 +1,85 @@ +using DotnetAffected.Testing.Utils; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Text.RegularExpressions; + +namespace DotnetAffected.Tasks.Tests +{ + public abstract class BaseAffectedTaskBuildTest : BaseRepositoryTest + { + private static readonly Regex ProjectPathRegEx = new Regex("^\\s*\\[AffectedProject](\\S.+)$", RegexOptions.Compiled); + + private readonly Process _buildProcess; + private readonly List _output = new List(); + private readonly List _errors = new List(); + private readonly HashSet _projects = new HashSet(); + + protected bool HasOutput => _output.Count > 0; + protected bool HasErrors => _errors.Count > 0; + protected bool HasProjects => _projects.Count > 0; + protected bool ExitSuccess => ExitCode == 0; + + protected IEnumerable Output => _output.AsEnumerable(); + protected IEnumerable Errors => _errors.AsEnumerable(); + protected IEnumerable Projects => _projects.AsEnumerable(); + protected int ExitCode { get; private set; } = -1; + + protected BaseAffectedTaskBuildTest() + { + _buildProcess = new Process(); + _buildProcess.EnableRaisingEvents = false; + _buildProcess.StartInfo.RedirectStandardError = true; + _buildProcess.StartInfo.RedirectStandardOutput = true; + _buildProcess.StartInfo.FileName = "dotnet"; + _buildProcess.StartInfo.WindowStyle = ProcessWindowStyle.Hidden; + _buildProcess.StartInfo.CreateNoWindow = true; + _buildProcess.StartInfo.WorkingDirectory = Repository.Path; + } + + protected override void Dispose(bool dispose) { + base.Dispose(dispose); + _buildProcess.Close(); + } + + protected void ExecuteCommandAndCollectResults() + { + _output.Clear(); + _errors.Clear(); + + _buildProcess.StartInfo.Arguments = $"msbuild -nodeReuse:false ci.props /t:DotnetAffectedCheck /p:DotnetAffectedNugetDir={Utils.DotnetAffectedNugetDir} /p:TargetFramework={Utils.TargetFramework}"; + + _buildProcess.OutputDataReceived += (_, eventArgs) => + { + if (string.IsNullOrWhiteSpace(eventArgs.Data)) + return; + + _output.Add(eventArgs.Data); + var match = ProjectPathRegEx.Match(eventArgs.Data); + if (match.Success) + { + var proj = match.Groups[1].Value; + if (!string.IsNullOrWhiteSpace(proj)) + _projects.Add(proj); + } + }; + + _buildProcess.ErrorDataReceived += (_, eventArgs) => + { + if (!string.IsNullOrWhiteSpace(eventArgs.Data)) + _errors.Add(eventArgs.Data); + }; + + _buildProcess.Start(); + _buildProcess.BeginOutputReadLine(); + _buildProcess.BeginErrorReadLine(); + _buildProcess.WaitForExit(); + + ExitCode = _buildProcess.ExitCode; + + _buildProcess.CancelOutputRead(); + _buildProcess.CancelErrorRead(); + + } + } +} diff --git a/test/DotnetAffected.Tasks.Tests/DotnetAffected.Tasks.Tests.csproj b/test/DotnetAffected.Tasks.Tests/DotnetAffected.Tasks.Tests.csproj new file mode 100644 index 0000000..8907a7f --- /dev/null +++ b/test/DotnetAffected.Tasks.Tests/DotnetAffected.Tasks.Tests.csproj @@ -0,0 +1,63 @@ + + + + + + + + + + + + <_DotnetAffectedNupkg Include="$(MSBuildThisFileDirectory)bin/*DotnetAffected.Tasks.*.nupkg" /> + + <_DotnetAffectedPkgFiles Include="$(MSBuildThisFileDirectory)$(OutputPath)/DotnetAffected/**/*.*" /> + <_DotnetAffectedPkgFiles Include="$(_DotnetAffectedNupkg)" /> + <_DotnetAffectedPkgFiles Include="$(MSBuildThisFileDirectory)bin/*DotnetAffected.Tasks.*.snupkg" /> + + + + + + + + + + <_DotnetAffectedNupkg Remove="@(_DotnetAffectedNupkg)" /> + <_DotnetAffectedNupkg Include="$(MSBuildThisFileDirectory)bin/*DotnetAffected.Tasks.*.nupkg" /> + + + + + + + + ResXFileCodeGenerator + TestProjectScenarios.Designer.cs + + + + + True + True + TestProjectScenarios.resx + + + diff --git a/test/DotnetAffected.Tasks.Tests/Extensions/TemporaryRepositoryExtensions.cs b/test/DotnetAffected.Tasks.Tests/Extensions/TemporaryRepositoryExtensions.cs new file mode 100644 index 0000000..2345a86 --- /dev/null +++ b/test/DotnetAffected.Tasks.Tests/Extensions/TemporaryRepositoryExtensions.cs @@ -0,0 +1,45 @@ +using DotnetAffected.Tasks.Tests.Resources; +using DotnetAffected.Testing.Utils; +using Microsoft.Build.Construction; +using System; +using System.IO; +using System.Threading.Tasks; + +namespace DotnetAffected.Tasks.Tests +{ + public static class TemporaryRepositoryExtensions + { + public static async Task PrepareTaskInfra(this TemporaryRepository repo, string? importResource = null) + { + await repo.CreateTextFileAsync("Directory.Build.props", TestProjectScenarios.DirectoryBuildProps); + await repo.CreateTextFileAsync("ci.props", TestProjectScenarios.CiProps); + + var hasImportResource = !string.IsNullOrWhiteSpace(importResource); + var isNetCoreApp31 = Utils.TargetFramework == "netcoreapp3.1"; + if (!hasImportResource && !isNetCoreApp31) + return; + + var ciProps = ProjectRootElement.Open(Path.Combine(repo.Path, "ci.props"))!; + + if (isNetCoreApp31) + { + // "ci.props" imports DotnetAffected.Tasks as an Sdk + // + // + // When we execute the build within the test we provide the property "DotnetAffectedNugetDir" with the lib's location + // For some reason it's not working in 3.1 so we override + foreach (var importElement in ciProps.Imports) + importElement.Sdk = Utils.DotnetAffectedNugetDir; + } + + if (hasImportResource) + { + var fileName = $"./{Guid.NewGuid().ToString()}.props"; + await repo.CreateTextFileAsync(fileName, importResource); + ciProps.AddImport(fileName); + } + + ciProps.Save(); + } + } +} diff --git a/test/DotnetAffected.Tasks.Tests/README.md b/test/DotnetAffected.Tasks.Tests/README.md new file mode 100644 index 0000000..0066bf1 --- /dev/null +++ b/test/DotnetAffected.Tasks.Tests/README.md @@ -0,0 +1,97 @@ +# DotnetAffected.Tasks.Tests + +We don't get a lot of value from unit testing an MSBuild task. +`DotnetAffected.Tasks` is mostly a wrapper around `DotnetAffected.Core` which +execute it within the context of an MSBuild process. + +To get proper feedback we need to test the task in a realistic build context. + +In other words, we need to perform integration tests where we run a build process +with `DotnetAffected.Tasks` registered and evaluate the results. + +## Why integration testing is required + +> It is always required in MSBuild Task packages but in this case even more! + +`DotnetAffected.Tasks` is an MSBuild task package. +MSBuild task packages run within the context of the build, not the application it is building. + +I.E MSBuild task package is a dynamic plugin, an external library to the executing assembly that was not present +when the assembly was compiled (MSBuild dll). + +This is why MSBuild task packages can not declare external dependencies (package references) +because there is not build process that will restore them. + +Instead MSBuild task packages must contain a physical copy of all dependencies they require within the distributed nuget file. +It is straight forward in most cases however, `DotnetAffected.Core` +depends on `LibGit2Sharp` which introduce some challenges. + +To simplify, `LibGit2Sharp` depends on native, os based, libraries which it dynamically link +at runtime. `LibGit2Sharp` comes with those libraries but it will load them assuming +they are physically located in a location relative to the executing assembly. +Oh no, the executing assembly is MSBuild thus **it will not be able to load them** because +they are in a location relative to physical location of `LibGit2Sharp` (which is where `DotnetAffected.Tasks` is) + +Luckily, `LibGit2Sharp` accept as an input the custom library to load the dynamic +libraries from. It has a bug which we also workaround here where it will look +for a file in the format `libgit2-XYZ.QQ` but in linux/osx the files are `git2-XYZ.QQ`. +We can only provide the path to the folder holding the libraries, we can't modify the name so +this package, when packaged, will ensure each `libgit2-XYZ.QQ` is also duplicated as `git2-XYZ.QQ`. + +> The workaround makes the lib's payload a bit bigger. +> TODO: Open issue in `LibGit2Sharp` + +## How testing is performed + +Each test has a dedicated `Repository` created in a temporary folder. +Each repo comes with 2 files, located in the repo root: + +- **Directory.Build.props** +Add the `PrintAffectedProjects` which runs after `DotnetAffected.Tasks` finished. +`PrintAffectedProjects` prints the full path to all projects detected by `DotnetAffected.Tasks` +The output then processed by `BaseAffectedTaskBuildTest`, which expose it for every +test class to use. +General output, errors and projects are available. + + +- **ci.props** +Registers `DotnetAffected.Tasks` as an Sdk. +Imports the test scenario `props` file to run. + +> **ci.props** can run without an additional file to import + +When a test execute a `Process` is spun that run's `dotne ci.props` with a specific target +that will execute `DotnetAffected.Tasks` and populate the item `ProjectReference` with +the project's detected by `DotnetAffected.Core`. + +## Test scenarios + +When we start a new repository we can provide an additional MSBuild project +file that will allow us to simulate test scenarios that will reflect different outcomes. + +We can do this using **before/after** events on the `PrintAffectedProjects` task. + +For example: +```xml + + + + + + + + + + + + + + +``` + +The above example will filter the `ProjectReference` collection to +contain affected projects that have the property `IsClientLibrary` set to `true`. + +Because it run **before** `PrintAffectedProjects`, `PrintAffectedProjects` will +print the filtered results thus in the test class we should expect only those filtered projects. + diff --git a/test/DotnetAffected.Tasks.Tests/Resources/TestProjectScenarios.Designer.cs b/test/DotnetAffected.Tasks.Tests/Resources/TestProjectScenarios.Designer.cs new file mode 100644 index 0000000..a6cb855 --- /dev/null +++ b/test/DotnetAffected.Tasks.Tests/Resources/TestProjectScenarios.Designer.cs @@ -0,0 +1,66 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace DotnetAffected.Tasks.Tests.Resources { + using System; + + + [System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] + [System.Diagnostics.DebuggerNonUserCodeAttribute()] + [System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class TestProjectScenarios { + + private static System.Resources.ResourceManager resourceMan; + + private static System.Globalization.CultureInfo resourceCulture; + + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal TestProjectScenarios() { + } + + [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)] + internal static System.Resources.ResourceManager ResourceManager { + get { + if (object.Equals(null, resourceMan)) { + System.Resources.ResourceManager temp = new System.Resources.ResourceManager("DotnetAffected.Tasks.Tests.Resources.TestProjectScenarios", typeof(TestProjectScenarios).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)] + internal static System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + internal static string DirectoryBuildProps { + get { + return ResourceManager.GetString("DirectoryBuildProps", resourceCulture); + } + } + + internal static string CiProps { + get { + return ResourceManager.GetString("CiProps", resourceCulture); + } + } + + internal static string AffectedFilterClass { + get { + return ResourceManager.GetString("AffectedFilterClass", resourceCulture); + } + } + } +} diff --git a/test/DotnetAffected.Tasks.Tests/Resources/TestProjectScenarios.resx b/test/DotnetAffected.Tasks.Tests/Resources/TestProjectScenarios.resx new file mode 100644 index 0000000..31ad032 --- /dev/null +++ b/test/DotnetAffected.Tasks.Tests/Resources/TestProjectScenarios.resx @@ -0,0 +1,62 @@ + + + + + + + + + + text/microsoft-resx + + + 1.3 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + + + + + +]]> + + + + + + +
+]]> + + + + + + + + + + + + + + + + + + + + +]]> + + + diff --git a/test/DotnetAffected.Tasks.Tests/Resources/when_task_filter_is_defined_only_changes_that_pass_the_filter_should_be_affected.props b/test/DotnetAffected.Tasks.Tests/Resources/when_task_filter_is_defined_only_changes_that_pass_the_filter_should_be_affected.props new file mode 100644 index 0000000..efd7e17 --- /dev/null +++ b/test/DotnetAffected.Tasks.Tests/Resources/when_task_filter_is_defined_only_changes_that_pass_the_filter_should_be_affected.props @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/test/DotnetAffected.Tasks.Tests/Utils.cs b/test/DotnetAffected.Tasks.Tests/Utils.cs new file mode 100644 index 0000000..658ae97 --- /dev/null +++ b/test/DotnetAffected.Tasks.Tests/Utils.cs @@ -0,0 +1,40 @@ +using System; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Text.RegularExpressions; + +namespace DotnetAffected.Tasks.Tests +{ + public static class Utils + { + public static string TargetFramework => TargetFrameworkLocal.Value; + + public static string DotnetAffectedNugetDir => Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location) ?? AppContext.BaseDirectory, "DotnetAffected"); + + private static readonly Lazy TargetFrameworkLocal = new (() => + { + var targetFramework = typeof(Utils).Assembly + .GetCustomAttributes(typeof(System.Runtime.Versioning.TargetFrameworkAttribute), false) + .OfType() + .Single() + .FrameworkName; + + var majorVersion = new Regex(".+,Version=v(\\d).(\\d)") + .Match(targetFramework) + .Groups.Values.Skip(1) + .Select(g => int.Parse(g.Value)) + .First(); + + switch (majorVersion) + { + case 3: + return "netcoreapp3.1"; + case >= 5: + return $"net{majorVersion}.0"; + default: + throw new NotSupportedException($"Invalid TargetFramework: {targetFramework}"); + } + }); + } +} diff --git a/test/DotnetAffected.Testing.Utils/DotnetAffected.Testing.Utils.csproj b/test/DotnetAffected.Testing.Utils/DotnetAffected.Testing.Utils.csproj index 177d5b5..05c043e 100644 --- a/test/DotnetAffected.Testing.Utils/DotnetAffected.Testing.Utils.csproj +++ b/test/DotnetAffected.Testing.Utils/DotnetAffected.Testing.Utils.csproj @@ -1,10 +1,10 @@ - + - - - + + + diff --git a/test/DotnetAffected.Testing.Utils/Repository/TemporaryRepositoryExtensions.cs b/test/DotnetAffected.Testing.Utils/Repository/TemporaryRepositoryExtensions.cs index 55926d5..0eb0084 100644 --- a/test/DotnetAffected.Testing.Utils/Repository/TemporaryRepositoryExtensions.cs +++ b/test/DotnetAffected.Testing.Utils/Repository/TemporaryRepositoryExtensions.cs @@ -30,6 +30,24 @@ public static ProjectRootElement CreateCsProject( return project; } + public static ProjectRootElement CreateCsProject( + this TemporaryRepository repo, + string projectName, + string projectTemplateFile, + Action customizer = null) + { + var path = Path.Combine(repo.Path, projectName, $"{projectName}.csproj"); + File.Copy(projectTemplateFile, path); + var project = ProjectRootElement + .Open(path) + .SetName(projectName); + + customizer?.Invoke(project); + + project.Save(); + + return project; + } public static ProjectRootElement CreateDirectoryPackageProps( this TemporaryRepository repo, Action customizer) diff --git a/test/DotnetAffected.Testing.Utils/TestingGraphExtensions.cs b/test/DotnetAffected.Testing.Utils/TestingGraphExtensions.cs index 3deccf3..702a29b 100644 --- a/test/DotnetAffected.Testing.Utils/TestingGraphExtensions.cs +++ b/test/DotnetAffected.Testing.Utils/TestingGraphExtensions.cs @@ -71,6 +71,22 @@ public static ProjectRootElement UpdateDirectoryPackageProps(this ProjectRootEle return project; } + public static void AddOrUpdateProperty(this ProjectRootElement element, string propertyName, string propertyValue) + { + var matches = element.Properties.Where(p => p.Name == propertyName).ToList(); + if (matches.Any()) + { + foreach (var pe in matches) + pe.Value = propertyValue; + } + else + { + element.AddProperty(propertyName, propertyValue); + } + + } + + public static void RemoveDirectoryPackageProps(this ProjectRootElement element) { element.DeleteFile("Directory.Packages.props");