From 59d92b05ee8f1fa857cf992de2a4b792214318a4 Mon Sep 17 00:00:00 2001 From: olivierus Date: Mon, 25 May 2026 09:33:17 +0200 Subject: [PATCH 1/6] feat: add multiple target framework support --- .../Builders/PackagesPropsBuilder.cs | 41 +++- .../Builders/ProjectBuilder.cs | 138 ++++++++--- .../MigratorCommand.cs | 3 +- .../NuGetPackageInfo.cs | 9 +- .../NuGetPackageInfoExtensions.cs | 89 ++++--- .../Builders/PackagesPropsBuilderTests.cs | 84 +++++++ .../Builders/ProjectBuilderTests.cs | 228 ++++++++++++++++++ .../EndToEndTests.cs | 188 +++++++++++++++ .../NuGetPackageInfoExtensionsTests.cs | 66 +++++ .../NuGetPackageInfoTests.cs | 131 +++++++++- 10 files changed, 897 insertions(+), 80 deletions(-) create mode 100644 tests/CentralPackageManagementMigrator.Tests/EndToEndTests.cs create mode 100644 tests/CentralPackageManagementMigrator.Tests/NuGetPackageInfoExtensionsTests.cs diff --git a/src/CentralPackageManagementMigrator/Builders/PackagesPropsBuilder.cs b/src/CentralPackageManagementMigrator/Builders/PackagesPropsBuilder.cs index 4c52013..dbd7194 100644 --- a/src/CentralPackageManagementMigrator/Builders/PackagesPropsBuilder.cs +++ b/src/CentralPackageManagementMigrator/Builders/PackagesPropsBuilder.cs @@ -79,19 +79,46 @@ private XmlDocument GenerateDocument(IEnumerable packages) propertyGroup.AppendChild(managePackageVersionsCentrally); project.AppendChild(propertyGroup); - var itemGroup = doc.CreateElement(string.Empty, "ItemGroup", string.Empty); + var sortedPackages = packages.OrderBy(x => x.Id, StringComparer.InvariantCultureIgnoreCase).ToList(); - foreach (var package in packages) + var unconditionalGroup = doc.CreateElement(string.Empty, "ItemGroup", string.Empty); + + var conditionalGroups = sortedPackages + .Where(p => p.Condition is not null) + .GroupBy(p => p.Condition!) + .ToList(); + + foreach (var package in sortedPackages.Where(p => p.Condition is null)) { - var packageVersion = doc.CreateElement(string.Empty, "PackageVersion", string.Empty); - packageVersion.SetAttribute("Include", package.Id); - packageVersion.SetAttribute("Version", package.Version); + AddPackageVersion(doc, unconditionalGroup, package); + } - itemGroup.AppendChild(packageVersion); + if (unconditionalGroup.HasChildNodes) + { + project.AppendChild(unconditionalGroup); } - project.AppendChild(itemGroup); + foreach (var group in conditionalGroups) + { + var itemGroup = doc.CreateElement(string.Empty, "ItemGroup", string.Empty); + itemGroup.SetAttribute("Condition", group.Key); + + foreach (var package in group.OrderBy(p => p.Id, StringComparer.InvariantCultureIgnoreCase)) + { + AddPackageVersion(doc, itemGroup, package); + } + + project.AppendChild(itemGroup); + } return doc; } + + private static void AddPackageVersion(XmlDocument doc, XmlElement itemGroup, NuGetPackageInfo package) + { + var packageVersion = doc.CreateElement(string.Empty, "PackageVersion", string.Empty); + packageVersion.SetAttribute("Include", package.Id); + packageVersion.SetAttribute("Version", package.Version); + itemGroup.AppendChild(packageVersion); + } } diff --git a/src/CentralPackageManagementMigrator/Builders/ProjectBuilder.cs b/src/CentralPackageManagementMigrator/Builders/ProjectBuilder.cs index f8b61bb..427ece0 100644 --- a/src/CentralPackageManagementMigrator/Builders/ProjectBuilder.cs +++ b/src/CentralPackageManagementMigrator/Builders/ProjectBuilder.cs @@ -67,6 +67,68 @@ public ReadOnlyDictionary> GetPackagesInAllProjec return allPackages.AsReadOnly(); } + /// + /// For unit tests. + /// + internal HashSet GetTargetFrameworksFromSource(string projectSource) + { + var doc = CreateXmlDocument(); + doc.LoadXml(projectSource); + var frameworks = new HashSet(); + + var single = doc.SelectSingleNode("//TargetFramework"); + if (single is not null) + { + frameworks.Add(single.InnerText.Trim()); + } + + var multiple = doc.SelectSingleNode("//TargetFrameworks"); + if (multiple is not null) + { + foreach (var tf in multiple.InnerText.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + frameworks.Add(tf); + } + } + + return frameworks; + } + + public HashSet GetTargetFrameworks(string searchPath) + { + const string searchPattern = "*.csproj"; + + _logger.LogInformation("Collecting target frameworks from {SearchPattern} files under: {SearchPath}", + searchPattern, searchPath); + var allProjects = Directory.GetFiles(searchPath, searchPattern, SearchOption.AllDirectories); + + var frameworks = new HashSet(); + + foreach (var projectFile in allProjects) + { + var doc = CreateXmlDocument(); + doc.Load(projectFile); + + var single = doc.SelectSingleNode("//TargetFramework"); + if (single is not null) + { + frameworks.Add(single.InnerText.Trim()); + continue; + } + + var multiple = doc.SelectSingleNode("//TargetFrameworks"); + if (multiple is not null) + { + foreach (var tf in multiple.InnerText.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + frameworks.Add(tf); + } + } + } + + return frameworks; + } + /// /// For unit tests. /// @@ -90,54 +152,52 @@ internal string UpdateProjectFromSource(string projectSource, List packages) { - foreach (var packageId in packages.Select(x => x.Id)) + foreach (var packageId in packages.Select(x => x.Id).Distinct()) { - _logger.LogDebug("Locating single PackageReference for Includes = {PackageId}", packageId); + _logger.LogDebug("Locating PackageReferences for Include = {PackageId}", packageId); - if (doc.SelectSingleNode($"//PackageReference[@Include='{packageId}']") is not XmlElement packageReference) + var matches = doc.SelectNodes($"//PackageReference[@Include='{packageId}']"); + + if (matches is null || matches.Count == 0) { _logger.LogInformation("Couldn't find any elements"); continue; } - var removedVersion = packageReference.Attributes?.Remove(packageReference.Attributes[VersionElementName]); - - if (removedVersion is not null) + foreach (XmlNode match in matches) { - _logger.LogInformation("Removed Version attribute for package {PackageId}", packageId); - continue; + RemoveVersionFromReference((XmlElement)match, packageId); } + } + } - _logger.LogDebug("No Version attribute found, looking for child element"); - var versionElement = packageReference.SelectSingleNode(VersionElementName); + private static void RemoveVersionFromReference(XmlElement packageReference, string packageId) + { + var removedVersion = packageReference.Attributes?.Remove(packageReference.Attributes[VersionElementName]); - if (versionElement is null) - { - _logger.LogInformation("No Version attribute and no Version child element for package {PackageId}", - packageId); - continue; - } + if (removedVersion is not null) + { + return; + } - packageReference.RemoveChild(versionElement); + var versionElement = packageReference.SelectSingleNode(VersionElementName); - // A Version child element means at least two children: one for - // whitespace before the element and then the element itself. - // Remove the leading whitespace. - foreach (var el in packageReference.ChildNodes.OfType()) - { - packageReference.RemoveChild(el); - } + if (versionElement is null) + { + return; + } - // If the PackageReference element only contained a Version - // element, then there will be an additional whitespace child - // element that precedes the closing PackageReference element. In - // this case, remove all whitespace and mark the element as - // self-closing. - if (string.IsNullOrWhiteSpace(packageReference.InnerText)) - { - packageReference.InnerXml = string.Empty; - packageReference.IsEmpty = true; - } + packageReference.RemoveChild(versionElement); + + foreach (var el in packageReference.ChildNodes.OfType()) + { + packageReference.RemoveChild(el); + } + + if (string.IsNullOrWhiteSpace(packageReference.InnerText)) + { + packageReference.InnerXml = string.Empty; + packageReference.IsEmpty = true; } } @@ -206,10 +266,14 @@ private List GetPackagesFromReferences(XmlNodeList packageRefe packageVersion = version.InnerText; } - _logger.LogDebug("Found NuGet package {PackageName} version {PackageVersion}", - packageName, packageVersion); + var condition = (packageReference.ParentNode as XmlElement)?.GetAttribute("Condition"); + var conditionValue = string.IsNullOrEmpty(condition) ? null : condition; + + _logger.LogDebug("Found NuGet package {PackageName} version {PackageVersion}{Condition}", + packageName, packageVersion, + conditionValue is not null ? $" condition: {conditionValue}" : ""); - packagesInProject.Add(new NuGetPackageInfo(packageName, packageVersion)); + packagesInProject.Add(new NuGetPackageInfo(packageName, packageVersion, conditionValue)); } return packagesInProject; diff --git a/src/CentralPackageManagementMigrator/MigratorCommand.cs b/src/CentralPackageManagementMigrator/MigratorCommand.cs index a9e7559..0a3be44 100644 --- a/src/CentralPackageManagementMigrator/MigratorCommand.cs +++ b/src/CentralPackageManagementMigrator/MigratorCommand.cs @@ -48,11 +48,12 @@ private static int Migrate(LogLevel logLevel) else { var projectBuilder = new ProjectBuilder(LoggingUtility.CreateLogger()); + var allTargetFrameworks = projectBuilder.GetTargetFrameworks(searchPath); var packages = projectBuilder.GetPackagesInAllProjects(searchPath); if (packages.Count > 0) { - var distinctPackages = packages.ToDistinctOrder(); + var distinctPackages = packages.ToDistinctOrder(allTargetFrameworks); directoryPackagesProps.WriteFile(distinctPackages); projectBuilder.UpdateProjects(packages); diff --git a/src/CentralPackageManagementMigrator/NuGetPackageInfo.cs b/src/CentralPackageManagementMigrator/NuGetPackageInfo.cs index 31f5c1b..2ca253f 100644 --- a/src/CentralPackageManagementMigrator/NuGetPackageInfo.cs +++ b/src/CentralPackageManagementMigrator/NuGetPackageInfo.cs @@ -4,11 +4,13 @@ internal class NuGetPackageInfo : IEquatable { public string Id { get; } public string Version { get; } + public string? Condition { get; } - public NuGetPackageInfo(string id, string version) + public NuGetPackageInfo(string id, string version, string? condition = null) { Id = id; Version = version; + Condition = condition; } public override bool Equals(object? obj) => Equals(obj as NuGetPackageInfo); @@ -25,8 +27,9 @@ public bool Equals(NuGetPackageInfo? other) } return Id.Equals(other.Id, StringComparison.InvariantCultureIgnoreCase) && - Version.Equals(other.Version); + Version.Equals(other.Version) && + Condition == other.Condition; } - public override int GetHashCode() => HashCode.Combine(Id.ToLowerInvariant(), Version); + public override int GetHashCode() => HashCode.Combine(Id.ToLowerInvariant(), Version, Condition); } diff --git a/src/CentralPackageManagementMigrator/NuGetPackageInfoExtensions.cs b/src/CentralPackageManagementMigrator/NuGetPackageInfoExtensions.cs index 7ad7d59..b2fe40a 100644 --- a/src/CentralPackageManagementMigrator/NuGetPackageInfoExtensions.cs +++ b/src/CentralPackageManagementMigrator/NuGetPackageInfoExtensions.cs @@ -1,50 +1,77 @@ using System.Collections.ObjectModel; +using System.Text.RegularExpressions; using CentralPackageManagementMigrator.Builders; namespace CentralPackageManagementMigrator; -/// -/// Intermediary extensions used by Command class to prepare data structures -/// between builders. -/// internal static class NuGetPackageInfoExtensions { - /// - /// Extracts all collections of NuGetPackageInfo objects to return a - /// collection of distinct NuGetPackageInfo objects in order by name. - /// - /// - /// The return from , - /// a dictionary of each project and the packages used. - /// - /// public static IEnumerable ToDistinctOrder( - this ReadOnlyDictionary> packages) + this ReadOnlyDictionary> packages, + HashSet allTargetFrameworks) { - return packages.SelectMany(x => x.Value) + var allEntries = packages.SelectMany(x => x.Value).Distinct(); - // Removes duplicates as defined by the implementation of - // IEquatable in NuGetPackageInfo. - .Distinct() - - // If there are multiple versions of the same package, keeps only - // a single one. - .GroupBy(x => x.Id) + var grouped = allEntries + .GroupBy(x => (x.Id, x.Condition)) .Select(MinimumPackageVersion) + .ToList(); - // Order by the package names. + return PromoteConditional(grouped, allTargetFrameworks) .OrderBy(x => x.Id, StringComparer.InvariantCultureIgnoreCase); } - /// - /// Finds the instance with the "lowest" version. - /// - /// A grouping of instances with the same package ID. - /// The instance with the lowest version. - private static NuGetPackageInfo MinimumPackageVersion(IGrouping arg) + private static NuGetPackageInfo MinimumPackageVersion( + IGrouping<(string Id, string? Condition), NuGetPackageInfo> arg) { - // A naive algorithm. This will likely need to be reworked some day - // when encountering more complex examples. return arg.OrderBy(x => x.Version).First(); } + + internal static List PromoteConditional( + List packages, + HashSet allTargetFrameworks) + { + var result = new List(); + + foreach (var idGroup in packages.GroupBy(x => x.Id)) + { + var unconditional = idGroup.FirstOrDefault(x => x.Condition is null); + + if (unconditional is not null) + { + result.Add(unconditional); + continue; + } + + var conditional = idGroup.ToList(); + var coveredTfs = conditional + .Select(x => ExtractTargetFramework(x.Condition)) + .Where(x => x is not null) + .Cast() + .ToHashSet(); + + if (coveredTfs.Count > 0 && coveredTfs.IsSupersetOf(allTargetFrameworks)) + { + var lowestVersion = conditional.OrderBy(x => x.Version).First(); + result.Add(new NuGetPackageInfo(lowestVersion.Id, lowestVersion.Version, null)); + } + else + { + result.AddRange(conditional); + } + } + + return result; + } + + internal static string? ExtractTargetFramework(string? condition) + { + if (string.IsNullOrEmpty(condition)) + { + return null; + } + + var match = Regex.Match(condition, @"==\s*'([^']+)'"); + return match.Success ? match.Groups[1].Value : null; + } } diff --git a/tests/CentralPackageManagementMigrator.Tests/Builders/PackagesPropsBuilderTests.cs b/tests/CentralPackageManagementMigrator.Tests/Builders/PackagesPropsBuilderTests.cs index 7ac57a2..785e5fa 100644 --- a/tests/CentralPackageManagementMigrator.Tests/Builders/PackagesPropsBuilderTests.cs +++ b/tests/CentralPackageManagementMigrator.Tests/Builders/PackagesPropsBuilderTests.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Linq; using CentralPackageManagementMigrator.Builders; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; @@ -31,6 +32,89 @@ public void BasicExample() Assert.Equal(expected, actual); } + [Fact] + public void MixOfUnconditionalAndConditional_UnconditionalGroupFirst() + { + var packages = new List + { + new("SomePackage", "8.0.0", "'$(TargetFramework)' == 'net8.0'"), + new("UniversalPackage", "1.0.0", null) + }; + + const string expected = """ + + + true + + + + + + + + + """; + + var actual = GenerateXml(packages); + Assert.Equal(expected, actual); + } + + [Fact] + public void MultipleConditionalGroups_EachWithCorrectCondition() + { + var packages = new List + { + new("PackageA", "6.0.0", "'$(TargetFramework)' == 'net6.0'"), + new("PackageB", "8.0.0", "'$(TargetFramework)' == 'net8.0'") + }; + + const string expected = """ + + + true + + + + + + + + + """; + + var actual = GenerateXml(packages); + Assert.Equal(expected, actual); + } + + [Fact] + public void PackagesSortedAlphabeticallyWithinGroups() + { + var packages = new List + { + new("ZPackage", "1.0.0", null), + new("APackage", "1.0.0", null) + }; + + var actual = GenerateXml(packages); + + var aPos = actual.IndexOf("APackage"); + var zPos = actual.IndexOf("ZPackage"); + Assert.True(aPos < zPos, "APackage should appear before ZPackage"); + } + + [Fact] + public void ConditionAttributeCopiedVerbatim() + { + var packages = new List + { + new("SomePackage", "6.0.0", "'$(TargetFramework)' == 'net6.0'") + }; + + var actual = GenerateXml(packages); + + Assert.Contains("Condition=\"'$(TargetFramework)' == 'net6.0'\"", actual); + } + private static string GenerateXml(List packages) { var logger = NullLoggerFactory.Instance.CreateLogger(); diff --git a/tests/CentralPackageManagementMigrator.Tests/Builders/ProjectBuilderTests.cs b/tests/CentralPackageManagementMigrator.Tests/Builders/ProjectBuilderTests.cs index 929738f..fdc9810 100644 --- a/tests/CentralPackageManagementMigrator.Tests/Builders/ProjectBuilderTests.cs +++ b/tests/CentralPackageManagementMigrator.Tests/Builders/ProjectBuilderTests.cs @@ -240,6 +240,234 @@ public void UpdateProjectFromSource_VersionAsChildElementWithExcludeAssets() UpdateProjectFromSource(csproj, expected); } + [Fact] + public void GetPackagesProjectSource_PackageReferenceInPlainItemGroup_ConditionIsNull() + { + const string csproj = """ + + + + + + """; + + var packages = GetPackagesProjectSource(csproj); + + Assert.Single(packages); + Assert.Equal("Newtonsoft.Json", packages[0].Id); + Assert.Null(packages[0].Condition); + } + + [Fact] + public void GetPackagesProjectSource_PackageReferenceInConditionalItemGroup_ConditionMatches() + { + const string csproj = """ + + + + + + """; + + var packages = GetPackagesProjectSource(csproj); + + Assert.Single(packages); + Assert.Equal("SomePackage", packages[0].Id); + Assert.Equal("'$(TargetFramework)' == 'net6.0'", packages[0].Condition); + } + + [Fact] + public void GetPackagesProjectSource_MultipleConditionalItemGroups_EachCarriesOwnCondition() + { + const string csproj = """ + + + + + + + + + """; + + var packages = GetPackagesProjectSource(csproj); + + Assert.Equal(2, packages.Count); + Assert.Equal("PackageA", packages[0].Id); + Assert.Equal("'$(TargetFramework)' == 'net6.0'", packages[0].Condition); + Assert.Equal("PackageB", packages[1].Id); + Assert.Equal("'$(TargetFramework)' == 'net8.0'", packages[1].Condition); + } + + [Fact] + public void GetPackagesProjectSource_PackageReferenceNoVersion_Skipped() + { + const string csproj = """ + + + + + + """; + + var packages = GetPackagesProjectSource(csproj); + + Assert.Empty(packages); + } + + [Fact] + public void GetPackagesProjectSource_NoPackageReferences_ReturnsEmpty() + { + const string csproj = """ + + + """; + + var packages = GetPackagesProjectSource(csproj); + + Assert.Empty(packages); + } + + [Fact] + public void UpdateProjectFromSource_VersionAttributeInConditionalItemGroup_Removed() + { + const string csproj = """ + + + + + + """; + + const string expected = """ + + + + + + """; + + UpdateProjectFromSource(csproj, expected); + } + + [Fact] + public void UpdateProjectFromSource_VersionAsChildElementInConditionalItemGroup_Removed() + { + const string csproj = """ + + + + 6.0.1 + + + + """; + + const string expected = """ + + + + + + """; + + UpdateProjectFromSource(csproj, expected); + } + + [Fact] + public void UpdateProjectFromSource_SamePackageInConditionalAndUnconditional_VersionRemovedFromBoth() + { + const string csproj = """ + + + + + + + + + """; + + const string expected = """ + + + + + + + + + """; + + var packages = new List + { + new("SomePackage", "1.0.0", null), + new("SomePackage", "8.0.0", "'$(TargetFramework)' == 'net8.0'") + }; + + var projectBuilder = GetBuilder(); + var actual = projectBuilder.UpdateProjectFromSource(csproj, packages); + Assert.Equal(expected, actual); + } + + [Fact] + public void GetPackagesProjectSource_SamePackageInConditionalAndUnconditional_BothCaptured() + { + const string csproj = """ + + + + + + + + + """; + + var packages = GetPackagesProjectSource(csproj); + + Assert.Equal(2, packages.Count); + var unconditional = Assert.Single(packages, p => p.Condition is null); + Assert.Equal("1.0.0", unconditional.Version); + var conditional = Assert.Single(packages, p => p.Condition is not null); + Assert.Equal("'$(TargetFramework)' == 'net8.0'", conditional.Condition); + Assert.Equal("8.0.0", conditional.Version); + } + + [Fact] + public void GetTargetFrameworks_SingleTargetFramework_ReturnsOne() + { + var frameworks = GetTargetFrameworksFromSource(""" + + + net8.0 + + + """); + + Assert.Equal(new HashSet { "net8.0" }, frameworks); + } + + [Fact] + public void GetTargetFrameworks_MultipleTargetFrameworks_ReturnsAll() + { + var frameworks = GetTargetFrameworksFromSource(""" + + + net6.0;net8.0 + + + """); + + Assert.Equal(new HashSet { "net6.0", "net8.0" }, frameworks); + } + + private static HashSet GetTargetFrameworksFromSource(string csprojSource) + { + var projectBuilder = GetBuilder(); + return projectBuilder.GetTargetFrameworksFromSource(csprojSource); + } + private static List GetPackagesProjectSource(string csproj) { var projectBuilder = GetBuilder(); diff --git a/tests/CentralPackageManagementMigrator.Tests/EndToEndTests.cs b/tests/CentralPackageManagementMigrator.Tests/EndToEndTests.cs new file mode 100644 index 0000000..7af894f --- /dev/null +++ b/tests/CentralPackageManagementMigrator.Tests/EndToEndTests.cs @@ -0,0 +1,188 @@ +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using CentralPackageManagementMigrator.Builders; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace CentralPackageManagementMigrator.Tests; + +public class EndToEndTests +{ + [Fact] + public void FullScenario_CorrectPropsFileAndVersionRemoval() + { + var logger = NullLoggerFactory.Instance.CreateLogger(); + + var packages = new ReadOnlyDictionary>( + new Dictionary> + { + { + "ProjectA", + [ + new NuGetPackageInfo("Newtonsoft.Json", "13.0.3", null) + ] + }, + { + "ProjectB", + [ + new NuGetPackageInfo("Newtonsoft.Json", "13.0.3", null), + new NuGetPackageInfo("Microsoft.Extensions.Hosting", "6.0.1", "'$(TargetFramework)' == 'net6.0'"), + new NuGetPackageInfo("Microsoft.Extensions.Hosting", "8.0.0", "'$(TargetFramework)' == 'net8.0'") + ] + }, + { + "ProjectC", + [ + new NuGetPackageInfo("Newtonsoft.Json", "12.0.3", null) + ] + } + }); + + var allTfs = new HashSet { "net6.0", "net8.0" }; + var distinctPackages = packages.ToDistinctOrder(allTfs).ToList(); + + Assert.Contains(distinctPackages, p => + p.Id == "Newtonsoft.Json" && + p.Version == "12.0.3" && + p.Condition is null); + + var hostingEntry = Assert.Single(distinctPackages, p => p.Id == "Microsoft.Extensions.Hosting"); + Assert.Equal("6.0.1", hostingEntry.Version); + Assert.Null(hostingEntry.Condition); + + var propsBuilderLogger = NullLoggerFactory.Instance.CreateLogger(); + var propsBuilder = new PackagesPropsBuilder(propsBuilderLogger, nameof(FullScenario_CorrectPropsFileAndVersionRemoval)); + var propsXml = propsBuilder.GenerateXml(distinctPackages); + + Assert.Contains("", propsXml); + Assert.Contains("", propsXml); + + Assert.DoesNotContain("", propsXml); + + var propertyGroupPos = propsXml.IndexOf("PropertyGroup", System.StringComparison.Ordinal); + var unconditionalGroupPos = propsXml.IndexOf("", System.StringComparison.Ordinal); + Assert.True(propertyGroupPos < unconditionalGroupPos, + "PropertyGroup should appear before ItemGroup"); + + var hostingPos = propsXml.IndexOf("Microsoft.Extensions.Hosting", System.StringComparison.Ordinal); + var newtonsoftPos = propsXml.IndexOf("Newtonsoft.Json", System.StringComparison.Ordinal); + Assert.True(hostingPos < newtonsoftPos, + "Packages should be sorted alphabetically"); + } + + [Fact] + public void PartialTfCoverage_PackagesStayConditional() + { + var packages = new ReadOnlyDictionary>( + new Dictionary> + { + { + "ProjectA", + [ + new NuGetPackageInfo("PackageU", "1.0.0", null) + ] + }, + { + "ProjectB", + [ + new NuGetPackageInfo("PackageV", "6.0.1", "'$(TargetFramework)' == 'net6.0'"), + new NuGetPackageInfo("PackageV", "8.0.0", "'$(TargetFramework)' == 'net8.0'"), + new NuGetPackageInfo("PackageW", "6.0.0", "'$(TargetFramework)' == 'net6.0'") + ] + }, + { + "ProjectC", + [ + new NuGetPackageInfo("PackageW", "5.0.0", "'$(TargetFramework)' == 'net5.0'") + ] + } + }); + + var allTfs = new HashSet { "net5.0", "net6.0", "net8.0" }; + var distinctPackages = packages.ToDistinctOrder(allTfs).ToList(); + + Assert.Contains(distinctPackages, p => + p.Id == "PackageU" && p.Version == "1.0.0" && p.Condition is null); + + var packageV = distinctPackages.Where(p => p.Id == "PackageV").ToList(); + Assert.Equal(2, packageV.Count); + Assert.Contains(packageV, p => p.Condition == "'$(TargetFramework)' == 'net6.0'" && p.Version == "6.0.1"); + Assert.Contains(packageV, p => p.Condition == "'$(TargetFramework)' == 'net8.0'" && p.Version == "8.0.0"); + + var packageW = distinctPackages.Where(p => p.Id == "PackageW").ToList(); + Assert.Equal(2, packageW.Count); + Assert.Contains(packageW, p => p.Condition == "'$(TargetFramework)' == 'net5.0'" && p.Version == "5.0.0"); + Assert.Contains(packageW, p => p.Condition == "'$(TargetFramework)' == 'net6.0'" && p.Version == "6.0.0"); + + var propsLogger = NullLoggerFactory.Instance.CreateLogger(); + var propsBuilder = new PackagesPropsBuilder(propsLogger, nameof(PartialTfCoverage_PackagesStayConditional)); + var propsXml = propsBuilder.GenerateXml(distinctPackages); + + Assert.Contains("", propsXml); + Assert.Contains("", propsXml); + Assert.Contains("", propsXml); + Assert.Contains("", propsXml); + + var unconditionalPos = propsXml.IndexOf("", System.StringComparison.Ordinal); + + var net5Group = propsXml.IndexOf(">( + new Dictionary> + { + { + "ProjectA", + [ + new NuGetPackageInfo("PackageX", "6.0.0", "'$(TargetFramework)' == 'net6.0'"), + new NuGetPackageInfo("PackageX", "7.0.0", "'$(TargetFramework)' == 'net7.0'"), + new NuGetPackageInfo("PackageX", "8.0.0", "'$(TargetFramework)' == 'net8.0'"), + new NuGetPackageInfo("PackageY", "6.0.0", "'$(TargetFramework)' == 'net6.0'"), + new NuGetPackageInfo("PackageY", "7.0.0", "'$(TargetFramework)' == 'net7.0'") + ] + } + }); + + var allTfs = new HashSet { "net6.0", "net7.0", "net8.0" }; + var distinctPackages = packages.ToDistinctOrder(allTfs).ToList(); + + var packageX = Assert.Single(distinctPackages, p => p.Id == "PackageX"); + Assert.Equal("6.0.0", packageX.Version); + Assert.Null(packageX.Condition); + + var packageY = distinctPackages.Where(p => p.Id == "PackageY").ToList(); + Assert.Equal(2, packageY.Count); + Assert.Contains(packageY, p => p.Condition == "'$(TargetFramework)' == 'net6.0'" && p.Version == "6.0.0"); + Assert.Contains(packageY, p => p.Condition == "'$(TargetFramework)' == 'net7.0'" && p.Version == "7.0.0"); + + var propsLogger = NullLoggerFactory.Instance.CreateLogger(); + var propsBuilder = new PackagesPropsBuilder(propsLogger, nameof(MixedPromotion_SomePackagesPromotedSomeStayConditional)); + var propsXml = propsBuilder.GenerateXml(distinctPackages); + + Assert.Contains("", propsXml); + Assert.Contains("", propsXml); + Assert.Contains("", propsXml); + + var unconditionalPos = propsXml.IndexOf("", System.StringComparison.Ordinal); + var net6GroupPos = propsXml.IndexOf(" + { + new("Pkg", "1.0.0", null), + new("Pkg", "2.0.0", "'cond1'"), + new("Pkg", "3.0.0", "'cond2'") + }; + + var result = NuGetPackageInfoExtensions.PromoteConditional(packages, []); + + Assert.Single(result); + Assert.Equal("1.0.0", result[0].Version); + Assert.Null(result[0].Condition); + } + + [Fact] + public void PromoteConditional_ConditionalsCoverAllTfs_PromotesToUnconditional() + { + var packages = new List + { + new("Pkg", "1.0.0", "'$(TargetFramework)' == 'net6.0'"), + new("Pkg", "2.0.0", "'$(TargetFramework)' == 'net8.0'") + }; + + var result = NuGetPackageInfoExtensions.PromoteConditional(packages, ["net6.0", "net8.0"]); + + Assert.Single(result); + Assert.Equal("1.0.0", result[0].Version); + Assert.Null(result[0].Condition); + } + + [Fact] + public void PromoteConditional_ConditionalsCoverSubset_KeepsPerCondition() + { + var packages = new List + { + new("Pkg", "1.0.0", "'$(TargetFramework)' == 'net6.0'") + }; + + var result = NuGetPackageInfoExtensions.PromoteConditional(packages, ["net6.0", "net8.0"]); + + Assert.Single(result); + Assert.Equal("1.0.0", result[0].Version); + Assert.Equal("'$(TargetFramework)' == 'net6.0'", result[0].Condition); + } +} diff --git a/tests/CentralPackageManagementMigrator.Tests/NuGetPackageInfoTests.cs b/tests/CentralPackageManagementMigrator.Tests/NuGetPackageInfoTests.cs index 71004ac..8b79809 100644 --- a/tests/CentralPackageManagementMigrator.Tests/NuGetPackageInfoTests.cs +++ b/tests/CentralPackageManagementMigrator.Tests/NuGetPackageInfoTests.cs @@ -159,5 +159,134 @@ public void ToDistinctOrder_PackageIdsInAlphabeticalOrder_SingleProject() } private static List Transform(Dictionary> packages) => - packages.AsReadOnly().ToDistinctOrder().ToList(); + packages.AsReadOnly().ToDistinctOrder([]).ToList(); + + private static List Transform(Dictionary> packages, HashSet allTargetFrameworks) => + packages.AsReadOnly().ToDistinctOrder(allTargetFrameworks).ToList(); +} + +public class PromotionTests +{ + [Fact] + public void PackageUnconditionallyAndConditionally_UnconditionalWins() + { + var packages = new Dictionary> + { + { + "ProjectA", + [ + new NuGetPackageInfo("SomePackage", "1.0.0", null) + ] + }, + { + "ProjectB", + [ + new NuGetPackageInfo("SomePackage", "6.0.1", "'$(TargetFramework)' == 'net6.0'") + ] + } + }; + + var result = Transform(packages, []); + + Assert.Single(result); + Assert.Equal("SomePackage", result[0].Id); + Assert.Equal("1.0.0", result[0].Version); + Assert.Null(result[0].Condition); + } + + [Fact] + public void PackageConditionallyForEveryFramework_PromotedToUnconditional() + { + var packages = new Dictionary> + { + { + "ProjectA", + [ + new NuGetPackageInfo("SomePackage", "6.0.1", "'$(TargetFramework)' == 'net6.0'"), + new NuGetPackageInfo("SomePackage", "8.0.0", "'$(TargetFramework)' == 'net8.0'") + ] + } + }; + + var allTfs = new HashSet { "net6.0", "net8.0" }; + var result = Transform(packages, allTfs); + + Assert.Single(result); + Assert.Equal("SomePackage", result[0].Id); + Assert.Equal("6.0.1", result[0].Version); + Assert.Null(result[0].Condition); + } + + [Fact] + public void PackageConditionallyForSubsetOfFrameworks_KeptInConditionalGroups() + { + var packages = new Dictionary> + { + { + "ProjectA", + [ + new NuGetPackageInfo("SomePackage", "6.0.1", "'$(TargetFramework)' == 'net6.0'"), + new NuGetPackageInfo("SomePackage", "8.0.0", "'$(TargetFramework)' == 'net8.0'") + ] + } + }; + + var allTfs = new HashSet { "net6.0", "net8.0", "net9.0" }; + var result = Transform(packages, allTfs); + + Assert.Equal(2, result.Count); + Assert.Contains(result, p => p.Condition == "'$(TargetFramework)' == 'net6.0'" && p.Version == "6.0.1"); + Assert.Contains(result, p => p.Condition == "'$(TargetFramework)' == 'net8.0'" && p.Version == "8.0.0"); + } + + [Fact] + public void SamePackageSameConditionDifferentVersions_LowestVersionWins() + { + var packages = new Dictionary> + { + { + "ProjectA", + [ + new NuGetPackageInfo("SomePackage", "8.0.0", "'$(TargetFramework)' == 'net8.0'") + ] + }, + { + "ProjectB", + [ + new NuGetPackageInfo("SomePackage", "6.0.0", "'$(TargetFramework)' == 'net8.0'") + ] + } + }; + + var result = Transform(packages, []); + + Assert.Single(result); + Assert.Equal("6.0.0", result[0].Version); + } + + [Fact] + public void SeparatePackagesNoSharedIds_AllPreserved() + { + var packages = new Dictionary> + { + { + "ProjectA", + [ + new NuGetPackageInfo("PackageA", "1.0.0"), + new NuGetPackageInfo("PackageB", "2.0.0") + ] + } + }; + + var result = Transform(packages, []); + + Assert.Equal(2, result.Count); + Assert.Contains(result, p => p.Id == "PackageA" && p.Version == "1.0.0"); + Assert.Contains(result, p => p.Id == "PackageB" && p.Version == "2.0.0"); + } + + private static List Transform(Dictionary> packages, HashSet allTargetFrameworks) + { + return packages.AsReadOnly().ToDistinctOrder(allTargetFrameworks).ToList(); + } } From 5a8b25faebccd7b891c5d00ef4539e2d028e5345 Mon Sep 17 00:00:00 2001 From: olivierus Date: Mon, 25 May 2026 09:36:43 +0200 Subject: [PATCH 2/6] feat: added path to arguments for more flexibility where to call --- .../MigratorCommand.cs | 14 +++- .../MigratorCommandTests.cs | 70 +++++++++++++++++-- 2 files changed, 76 insertions(+), 8 deletions(-) diff --git a/src/CentralPackageManagementMigrator/MigratorCommand.cs b/src/CentralPackageManagementMigrator/MigratorCommand.cs index 0a3be44..da212b1 100644 --- a/src/CentralPackageManagementMigrator/MigratorCommand.cs +++ b/src/CentralPackageManagementMigrator/MigratorCommand.cs @@ -14,18 +14,26 @@ internal class MigratorCommand : RootCommand DefaultValueFactory = _ => LogLevel.Information }; + private readonly Option _pathOption = new("--path", "-p") + { + Description = "Directory to search for project files under.", + DefaultValueFactory = _ => new DirectoryInfo(Directory.GetCurrentDirectory()) + }; + public MigratorCommand() : base(CommandDescription) { Options.Add(_logLevelOption); + Options.Add(_pathOption); SetAction(parseResult => { var logLevel = parseResult.GetRequiredValue(_logLevelOption); - return Migrate(logLevel); + var path = parseResult.GetRequiredValue(_pathOption); + return Migrate(logLevel, path); }); } - private static int Migrate(LogLevel logLevel) + private static int Migrate(LogLevel logLevel, DirectoryInfo? path = null) { var exitCode = 0; LoggingUtility.SetupLogging(logLevel); @@ -33,7 +41,7 @@ private static int Migrate(LogLevel logLevel) logger.LogDebug("Called with verbosity: {Level}", logLevel.ToString()); - var searchPath = Directory.GetCurrentDirectory(); + var searchPath = path?.FullName ?? Directory.GetCurrentDirectory(); logger.LogInformation("Adding central package management under search path: {SearchPath}", searchPath); var directoryPackagesProps = new PackagesPropsBuilder(LoggingUtility.CreateLogger(), diff --git a/tests/CentralPackageManagementMigrator.Tests/MigratorCommandTests.cs b/tests/CentralPackageManagementMigrator.Tests/MigratorCommandTests.cs index e976b21..b82d15e 100644 --- a/tests/CentralPackageManagementMigrator.Tests/MigratorCommandTests.cs +++ b/tests/CentralPackageManagementMigrator.Tests/MigratorCommandTests.cs @@ -1,5 +1,6 @@ using System.CommandLine; using System.CommandLine.Help; +using System.IO; using System.Linq; using Microsoft.Extensions.Logging; using Xunit; @@ -9,14 +10,15 @@ namespace CentralPackageManagementMigrator.Tests; public class MigratorCommandTests { [Fact] - public void Command_Options_ContainsHelpDescriptionVerbosity() + public void Command_Options_ContainsHelpDescriptionVerbosityAndPath() { var command = CreateCommand(); - Assert.Equal(3, command.Options.Count); + Assert.Equal(4, command.Options.Count); Assert.Single(command.Options.OfType()); Assert.Single(command.Options.OfType()); Assert.Single(command.Options.OfType>()); + Assert.Single(command.Options.OfType>()); } [Fact] @@ -40,15 +42,33 @@ public void VerbosityOptions_Contains_Description() Assert.Contains("Verbosity", verbosityOption.Description); } + [Fact] + public void PathOption_Contains_Aliases() + { + var command = CreateCommand(); + var pathOption = GetPathOption(command); + + Assert.Equal("--path", pathOption.Name); + Assert.Single(pathOption.Aliases); + Assert.Equal("-p", pathOption.Aliases.Single()); + } + + [Fact] + public void PathOption_Contains_Description() + { + var command = CreateCommand(); + var pathOption = GetPathOption(command); + + Assert.NotNull(pathOption.Description); + Assert.Contains("Directory", pathOption.Description); + } + [Fact] public void Parse_UnknownOption_ReportsError() { var command = CreateCommand(); var parseResult = command.Parse(["--unknown-option-doesnt-exist", nameof(Parse_UnknownOption_ReportsError)]); - // The first arg isn't recognized as a valid option so the second - // isn't recognized as the value for the first arg. Instead, they both - // should be flagged as invalid. Assert.Equal(2, parseResult.Errors.Count); } @@ -64,6 +84,19 @@ public void Parse_NoArguments_UsesInformationVerbosity() Assert.Equal(LogLevel.Information, parseResult.GetValue(verbosityOption)); } + [Fact] + public void Parse_NoArguments_UsesCurrentDirectory() + { + var command = CreateCommand(); + var pathOption = GetPathOption(command); + + var parseResult = command.Parse([]); + + Assert.Empty(parseResult.Errors); + Assert.NotNull(parseResult.GetValue(pathOption)); + Assert.Equal(Directory.GetCurrentDirectory(), parseResult.GetValue(pathOption)!.FullName); + } + [Theory] [InlineData("--verbosity", "Debug", LogLevel.Debug)] [InlineData("--verbosity", "Warning", LogLevel.Warning)] @@ -89,8 +122,35 @@ public void Parse_VerbosityInvalid_ReportsError() Assert.Single(parseResult.Errors); } + [Theory] + [InlineData("--path", "/tmp")] + [InlineData("-p", "/tmp")] + public void Parse_Path_ReturnsDirectory(string flag, string pathValue) + { + var command = CreateCommand(); + var pathOption = GetPathOption(command); + + var parseResult = command.Parse([flag, pathValue]); + + Assert.Empty(parseResult.Errors); + Assert.NotNull(parseResult.GetValue(pathOption)); + Assert.Equal(pathValue, parseResult.GetValue(pathOption)!.FullName); + } + + [Fact] + public void Parse_PathInvalid_ReportsError() + { + var command = CreateCommand(); + var parseResult = command.Parse(["--path", ""]); + + Assert.Single(parseResult.Errors); + } + private static MigratorCommand CreateCommand() => []; private static Option GetVerbosityOption(MigratorCommand command) => command.Options.OfType>().Single(); + + private static Option GetPathOption(MigratorCommand command) => + command.Options.OfType>().Single(); } From bba91db86670eeba6987986016df793115131cb1 Mon Sep 17 00:00:00 2001 From: olivierus Date: Mon, 25 May 2026 22:49:59 +0200 Subject: [PATCH 3/6] fix: packges not being found --- .../NuGetPackageInfoExtensions.cs | 14 ++++- .../NuGetPackageInfoExtensionsTests.cs | 24 +++++++- .../NuGetPackageInfoTests.cs | 59 ++++++++++++++++++- 3 files changed, 89 insertions(+), 8 deletions(-) diff --git a/src/CentralPackageManagementMigrator/NuGetPackageInfoExtensions.cs b/src/CentralPackageManagementMigrator/NuGetPackageInfoExtensions.cs index b2fe40a..49b6b03 100644 --- a/src/CentralPackageManagementMigrator/NuGetPackageInfoExtensions.cs +++ b/src/CentralPackageManagementMigrator/NuGetPackageInfoExtensions.cs @@ -13,7 +13,7 @@ public static IEnumerable ToDistinctOrder( var allEntries = packages.SelectMany(x => x.Value).Distinct(); var grouped = allEntries - .GroupBy(x => (x.Id, x.Condition)) + .GroupBy(x => (x.Id.ToLowerInvariant(), x.Condition)) .Select(MinimumPackageVersion) .ToList(); @@ -39,7 +39,17 @@ internal static List PromoteConditional( if (unconditional is not null) { - result.Add(unconditional); + var conditionalEntries = idGroup.Where(x => x.Condition is not null).ToList(); + + if (conditionalEntries.Count == 0 || conditionalEntries.All(x => x.Version == unconditional.Version)) + { + result.Add(unconditional); + } + else + { + result.AddRange(conditionalEntries); + } + continue; } diff --git a/tests/CentralPackageManagementMigrator.Tests/NuGetPackageInfoExtensionsTests.cs b/tests/CentralPackageManagementMigrator.Tests/NuGetPackageInfoExtensionsTests.cs index 669bc84..acf4f2c 100644 --- a/tests/CentralPackageManagementMigrator.Tests/NuGetPackageInfoExtensionsTests.cs +++ b/tests/CentralPackageManagementMigrator.Tests/NuGetPackageInfoExtensionsTests.cs @@ -17,13 +17,13 @@ public void ExtractTargetFramework_ReturnsExpected(string? condition, string? ex } [Fact] - public void PromoteConditional_UnconditionalExists_DropsConditionals() + public void PromoteConditional_UnconditionalExistsWithSameVersionConditionals_DropsConditionals() { var packages = new List { new("Pkg", "1.0.0", null), - new("Pkg", "2.0.0", "'cond1'"), - new("Pkg", "3.0.0", "'cond2'") + new("Pkg", "1.0.0", "'cond1'"), + new("Pkg", "1.0.0", "'cond2'") }; var result = NuGetPackageInfoExtensions.PromoteConditional(packages, []); @@ -33,6 +33,24 @@ public void PromoteConditional_UnconditionalExists_DropsConditionals() Assert.Null(result[0].Condition); } + [Fact] + public void PromoteConditional_UnconditionalExistsWithDifferentVersionConditionals_KeepsConditionals() + { + var packages = new List + { + new("Pkg", "1.0.0", null), + new("Pkg", "2.0.0", "'$(TargetFramework)' == 'net6.0'"), + new("Pkg", "3.0.0", "'$(TargetFramework)' == 'net8.0'") + }; + + var result = NuGetPackageInfoExtensions.PromoteConditional(packages, ["net6.0", "net8.0"]); + + Assert.Equal(2, result.Count); + Assert.Contains(result, p => p.Condition == "'$(TargetFramework)' == 'net6.0'" && p.Version == "2.0.0"); + Assert.Contains(result, p => p.Condition == "'$(TargetFramework)' == 'net8.0'" && p.Version == "3.0.0"); + Assert.DoesNotContain(result, p => p.Condition is null); + } + [Fact] public void PromoteConditional_ConditionalsCoverAllTfs_PromotesToUnconditional() { diff --git a/tests/CentralPackageManagementMigrator.Tests/NuGetPackageInfoTests.cs b/tests/CentralPackageManagementMigrator.Tests/NuGetPackageInfoTests.cs index 8b79809..a2cd679 100644 --- a/tests/CentralPackageManagementMigrator.Tests/NuGetPackageInfoTests.cs +++ b/tests/CentralPackageManagementMigrator.Tests/NuGetPackageInfoTests.cs @@ -158,6 +158,32 @@ public void ToDistinctOrder_PackageIdsInAlphabeticalOrder_SingleProject() Assert.Equal("Some.Package", actual[6].Id); } + [Fact] + public void ToDistinctOrder_SamePackageDifferentCasing_Deduplicates() + { + var packages = new Dictionary> + { + { + "ProjectA", + [ + new NuGetPackageInfo("newtonsoft.json", "13.0.1") + ] + }, + { + "ProjectB", + [ + new NuGetPackageInfo("Newtonsoft.Json", "12.0.3") + ] + } + }; + + var actual = Transform(packages); + + Assert.Single(actual); + Assert.Equal("12.0.3", actual[0].Version); + Assert.Equal("Newtonsoft.Json", actual[0].Id, ignoreCase: true); + } + private static List Transform(Dictionary> packages) => packages.AsReadOnly().ToDistinctOrder([]).ToList(); @@ -168,7 +194,7 @@ private static List Transform(Dictionary> { @@ -190,8 +216,8 @@ public void PackageUnconditionallyAndConditionally_UnconditionalWins() Assert.Single(result); Assert.Equal("SomePackage", result[0].Id); - Assert.Equal("1.0.0", result[0].Version); - Assert.Null(result[0].Condition); + Assert.Equal("6.0.1", result[0].Version); + Assert.Equal("'$(TargetFramework)' == 'net6.0'", result[0].Condition); } [Fact] @@ -239,6 +265,33 @@ public void PackageConditionallyForSubsetOfFrameworks_KeptInConditionalGroups() Assert.Contains(result, p => p.Condition == "'$(TargetFramework)' == 'net8.0'" && p.Version == "8.0.0"); } + [Fact] + public void PackageUnconditionallyAndConditionallyWithSameVersion_DropsConditionals() + { + var packages = new Dictionary> + { + { + "ProjectA", + [ + new NuGetPackageInfo("SomePackage", "1.0.0", null) + ] + }, + { + "ProjectB", + [ + new NuGetPackageInfo("SomePackage", "1.0.0", "'$(TargetFramework)' == 'net6.0'") + ] + } + }; + + var result = Transform(packages, []); + + Assert.Single(result); + Assert.Equal("SomePackage", result[0].Id); + Assert.Equal("1.0.0", result[0].Version); + Assert.Null(result[0].Condition); + } + [Fact] public void SamePackageSameConditionDifferentVersions_LowestVersionWins() { From de4370a7108965bf012afc9232be58a9c82f1a45 Mon Sep 17 00:00:00 2001 From: olivierus Date: Mon, 25 May 2026 22:57:39 +0200 Subject: [PATCH 4/6] fix: prefer major version instead of minor version in confict --- .../NuGetPackageInfoExtensions.cs | 17 ++++++--- .../EndToEndTests.cs | 38 +++++++++---------- .../NuGetPackageInfoExtensionsTests.cs | 19 +++++++++- .../NuGetPackageInfoTests.cs | 23 ++++++----- 4 files changed, 58 insertions(+), 39 deletions(-) diff --git a/src/CentralPackageManagementMigrator/NuGetPackageInfoExtensions.cs b/src/CentralPackageManagementMigrator/NuGetPackageInfoExtensions.cs index 49b6b03..7df8557 100644 --- a/src/CentralPackageManagementMigrator/NuGetPackageInfoExtensions.cs +++ b/src/CentralPackageManagementMigrator/NuGetPackageInfoExtensions.cs @@ -14,17 +14,17 @@ public static IEnumerable ToDistinctOrder( var grouped = allEntries .GroupBy(x => (x.Id.ToLowerInvariant(), x.Condition)) - .Select(MinimumPackageVersion) + .Select(MaximumPackageVersion) .ToList(); return PromoteConditional(grouped, allTargetFrameworks) .OrderBy(x => x.Id, StringComparer.InvariantCultureIgnoreCase); } - private static NuGetPackageInfo MinimumPackageVersion( + private static NuGetPackageInfo MaximumPackageVersion( IGrouping<(string Id, string? Condition), NuGetPackageInfo> arg) { - return arg.OrderBy(x => x.Version).First(); + return arg.OrderByDescending(x => x.Version).First(); } internal static List PromoteConditional( @@ -62,8 +62,15 @@ internal static List PromoteConditional( if (coveredTfs.Count > 0 && coveredTfs.IsSupersetOf(allTargetFrameworks)) { - var lowestVersion = conditional.OrderBy(x => x.Version).First(); - result.Add(new NuGetPackageInfo(lowestVersion.Id, lowestVersion.Version, null)); + var distinctVersions = conditional.Select(x => x.Version).Distinct().ToList(); + if (distinctVersions.Count == 1) + { + result.Add(new NuGetPackageInfo(conditional[0].Id, distinctVersions[0], null)); + } + else + { + result.AddRange(conditional); + } } else { diff --git a/tests/CentralPackageManagementMigrator.Tests/EndToEndTests.cs b/tests/CentralPackageManagementMigrator.Tests/EndToEndTests.cs index 7af894f..f6ca41a 100644 --- a/tests/CentralPackageManagementMigrator.Tests/EndToEndTests.cs +++ b/tests/CentralPackageManagementMigrator.Tests/EndToEndTests.cs @@ -45,31 +45,27 @@ public void FullScenario_CorrectPropsFileAndVersionRemoval() Assert.Contains(distinctPackages, p => p.Id == "Newtonsoft.Json" && - p.Version == "12.0.3" && + p.Version == "13.0.3" && p.Condition is null); - var hostingEntry = Assert.Single(distinctPackages, p => p.Id == "Microsoft.Extensions.Hosting"); - Assert.Equal("6.0.1", hostingEntry.Version); - Assert.Null(hostingEntry.Condition); + var hosting = distinctPackages.Where(p => p.Id == "Microsoft.Extensions.Hosting").ToList(); + Assert.Equal(2, hosting.Count); + Assert.Contains(hosting, p => p.Condition == "'$(TargetFramework)' == 'net6.0'" && p.Version == "6.0.1"); + Assert.Contains(hosting, p => p.Condition == "'$(TargetFramework)' == 'net8.0'" && p.Version == "8.0.0"); + Assert.DoesNotContain(hosting, p => p.Condition is null); var propsBuilderLogger = NullLoggerFactory.Instance.CreateLogger(); var propsBuilder = new PackagesPropsBuilder(propsBuilderLogger, nameof(FullScenario_CorrectPropsFileAndVersionRemoval)); var propsXml = propsBuilder.GenerateXml(distinctPackages); - Assert.Contains("", propsXml); + Assert.Contains("", propsXml); Assert.Contains("", propsXml); - - Assert.DoesNotContain("", propsXml); + Assert.Contains("", propsXml); var propertyGroupPos = propsXml.IndexOf("PropertyGroup", System.StringComparison.Ordinal); var unconditionalGroupPos = propsXml.IndexOf("", System.StringComparison.Ordinal); Assert.True(propertyGroupPos < unconditionalGroupPos, "PropertyGroup should appear before ItemGroup"); - - var hostingPos = propsXml.IndexOf("Microsoft.Extensions.Hosting", System.StringComparison.Ordinal); - var newtonsoftPos = propsXml.IndexOf("Newtonsoft.Json", System.StringComparison.Ordinal); - Assert.True(hostingPos < newtonsoftPos, - "Packages should be sorted alphabetically"); } [Fact] @@ -157,9 +153,11 @@ public void MixedPromotion_SomePackagesPromotedSomeStayConditional() var allTfs = new HashSet { "net6.0", "net7.0", "net8.0" }; var distinctPackages = packages.ToDistinctOrder(allTfs).ToList(); - var packageX = Assert.Single(distinctPackages, p => p.Id == "PackageX"); - Assert.Equal("6.0.0", packageX.Version); - Assert.Null(packageX.Condition); + var packageX = distinctPackages.Where(p => p.Id == "PackageX").ToList(); + Assert.Equal(3, packageX.Count); + Assert.Contains(packageX, p => p.Condition == "'$(TargetFramework)' == 'net6.0'" && p.Version == "6.0.0"); + Assert.Contains(packageX, p => p.Condition == "'$(TargetFramework)' == 'net7.0'" && p.Version == "7.0.0"); + Assert.Contains(packageX, p => p.Condition == "'$(TargetFramework)' == 'net8.0'" && p.Version == "8.0.0"); var packageY = distinctPackages.Where(p => p.Id == "PackageY").ToList(); Assert.Equal(2, packageY.Count); @@ -171,18 +169,16 @@ public void MixedPromotion_SomePackagesPromotedSomeStayConditional() var propsXml = propsBuilder.GenerateXml(distinctPackages); Assert.Contains("", propsXml); + Assert.Contains("", propsXml); + Assert.Contains("", propsXml); Assert.Contains("", propsXml); Assert.Contains("", propsXml); - var unconditionalPos = propsXml.IndexOf("", System.StringComparison.Ordinal); var net6GroupPos = propsXml.IndexOf(" { @@ -62,6 +62,23 @@ public void PromoteConditional_ConditionalsCoverAllTfs_PromotesToUnconditional() var result = NuGetPackageInfoExtensions.PromoteConditional(packages, ["net6.0", "net8.0"]); + Assert.Equal(2, result.Count); + Assert.Contains(result, p => p.Condition == "'$(TargetFramework)' == 'net6.0'" && p.Version == "1.0.0"); + Assert.Contains(result, p => p.Condition == "'$(TargetFramework)' == 'net8.0'" && p.Version == "2.0.0"); + Assert.DoesNotContain(result, p => p.Condition is null); + } + + [Fact] + public void PromoteConditional_ConditionalsCoverAllTfsWithSameVersion_PromotesToUnconditional() + { + var packages = new List + { + new("Pkg", "1.0.0", "'$(TargetFramework)' == 'net6.0'"), + new("Pkg", "1.0.0", "'$(TargetFramework)' == 'net8.0'") + }; + + var result = NuGetPackageInfoExtensions.PromoteConditional(packages, ["net6.0", "net8.0"]); + Assert.Single(result); Assert.Equal("1.0.0", result[0].Version); Assert.Null(result[0].Condition); diff --git a/tests/CentralPackageManagementMigrator.Tests/NuGetPackageInfoTests.cs b/tests/CentralPackageManagementMigrator.Tests/NuGetPackageInfoTests.cs index a2cd679..0b46baa 100644 --- a/tests/CentralPackageManagementMigrator.Tests/NuGetPackageInfoTests.cs +++ b/tests/CentralPackageManagementMigrator.Tests/NuGetPackageInfoTests.cs @@ -62,11 +62,11 @@ public void ToDistinctOrder() var actual = Transform(packages); Assert.Single(actual); - Assert.Equal("1.2.3", actual[0].Version); + Assert.Equal("4.5.6", actual[0].Version); } [Fact] - public void ToDistinctOrder_MultiplePackageIds_DifferentVersions_MinimumUsed() + public void ToDistinctOrder_MultiplePackageIds_DifferentVersions_MaximumUsed() { var packages = new Dictionary> { @@ -83,7 +83,7 @@ public void ToDistinctOrder_MultiplePackageIds_DifferentVersions_MinimumUsed() var actual = Transform(packages); Assert.Single(actual); - Assert.Equal("16.3.6", actual[0].Version); + Assert.Equal("18.0.0", actual[0].Version); } [Fact] @@ -180,8 +180,7 @@ public void ToDistinctOrder_SamePackageDifferentCasing_Deduplicates() var actual = Transform(packages); Assert.Single(actual); - Assert.Equal("12.0.3", actual[0].Version); - Assert.Equal("Newtonsoft.Json", actual[0].Id, ignoreCase: true); + Assert.Equal("13.0.1", actual[0].Version); } private static List Transform(Dictionary> packages) => @@ -221,7 +220,7 @@ public void PackageUnconditionallyAndConditionallyWithDifferentVersions_KeepsCon } [Fact] - public void PackageConditionallyForEveryFramework_PromotedToUnconditional() + public void PackageConditionallyForEveryFrameworkWithDifferentVersions_KeepsConditional() { var packages = new Dictionary> { @@ -237,10 +236,10 @@ public void PackageConditionallyForEveryFramework_PromotedToUnconditional() var allTfs = new HashSet { "net6.0", "net8.0" }; var result = Transform(packages, allTfs); - Assert.Single(result); - Assert.Equal("SomePackage", result[0].Id); - Assert.Equal("6.0.1", result[0].Version); - Assert.Null(result[0].Condition); + Assert.Equal(2, result.Count); + Assert.Contains(result, p => p.Condition == "'$(TargetFramework)' == 'net6.0'" && p.Version == "6.0.1"); + Assert.Contains(result, p => p.Condition == "'$(TargetFramework)' == 'net8.0'" && p.Version == "8.0.0"); + Assert.DoesNotContain(result, p => p.Condition is null); } [Fact] @@ -293,7 +292,7 @@ public void PackageUnconditionallyAndConditionallyWithSameVersion_DropsCondition } [Fact] - public void SamePackageSameConditionDifferentVersions_LowestVersionWins() + public void SamePackageSameConditionDifferentVersions_HighestVersionWins() { var packages = new Dictionary> { @@ -314,7 +313,7 @@ public void SamePackageSameConditionDifferentVersions_LowestVersionWins() var result = Transform(packages, []); Assert.Single(result); - Assert.Equal("6.0.0", result[0].Version); + Assert.Equal("8.0.0", result[0].Version); } [Fact] From 9b4235dc4d57094bb43c52534659ffd6942acd72 Mon Sep 17 00:00:00 2001 From: olivierus Date: Fri, 29 May 2026 07:40:12 +0200 Subject: [PATCH 5/6] fix: empty condition in multi tf should still have a condition --- .../Builders/ProjectBuilder.cs | 64 +++++++++++++- .../NuGetPackageInfo.cs | 2 + .../Builders/ProjectBuilderTests.cs | 86 +++++++++++++++++++ 3 files changed, 151 insertions(+), 1 deletion(-) diff --git a/src/CentralPackageManagementMigrator/Builders/ProjectBuilder.cs b/src/CentralPackageManagementMigrator/Builders/ProjectBuilder.cs index 427ece0..f9999c2 100644 --- a/src/CentralPackageManagementMigrator/Builders/ProjectBuilder.cs +++ b/src/CentralPackageManagementMigrator/Builders/ProjectBuilder.cs @@ -54,6 +54,8 @@ public ReadOnlyDictionary> GetPackagesInAllProjec var packagesInProject = GetPackagesInProject(projectDocument); + packagesInProject = ApplyTargetFrameworkCondition(packagesInProject, GetProjectTargetFrameworks(projectDocument)); + if (packagesInProject.Count > 0) { allPackages.Add(projectFile, packagesInProject); @@ -129,6 +131,65 @@ public HashSet GetTargetFrameworks(string searchPath) return frameworks; } + private static HashSet GetProjectTargetFrameworks(XmlDocument doc) + { + var frameworks = new HashSet(); + + var single = doc.SelectSingleNode("//TargetFramework"); + if (single is not null) + { + frameworks.Add(single.InnerText.Trim()); + return frameworks; + } + + var multiple = doc.SelectSingleNode("//TargetFrameworks"); + if (multiple is not null) + { + foreach (var tf in multiple.InnerText.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + frameworks.Add(tf); + } + } + + return frameworks; + } + + private static List ApplyTargetFrameworkCondition(List packages, HashSet projectTfs) + { + if (projectTfs.Count == 0) + { + return packages; + } + + if (projectTfs.Count == 1) + { + var singleTf = projectTfs.Single(); + var tfCondition = $"'$(TargetFramework)' == '{singleTf}'"; + return packages.Select(pkg => pkg.Condition is null + ? pkg.WithCondition(tfCondition) + : pkg.WithCondition($"{pkg.Condition} and {tfCondition}")) + .ToList(); + } + + var result = new List(packages.Count); + foreach (var pkg in packages) + { + if (pkg.Condition is null) + { + foreach (var tf in projectTfs) + { + result.Add(pkg.WithCondition($"'$(TargetFramework)' == '{tf}'")); + } + } + else + { + result.Add(pkg); + } + } + + return result; + } + /// /// For unit tests. /// @@ -136,7 +197,8 @@ internal List GetPackagesProjectSource(string projectSource) { var projectDocument = CreateXmlDocument(); projectDocument.LoadXml(projectSource); - return GetPackagesInProject(projectDocument); + var packages = GetPackagesInProject(projectDocument); + return ApplyTargetFrameworkCondition(packages, GetProjectTargetFrameworks(projectDocument)); } /// diff --git a/src/CentralPackageManagementMigrator/NuGetPackageInfo.cs b/src/CentralPackageManagementMigrator/NuGetPackageInfo.cs index 2ca253f..8ecb369 100644 --- a/src/CentralPackageManagementMigrator/NuGetPackageInfo.cs +++ b/src/CentralPackageManagementMigrator/NuGetPackageInfo.cs @@ -13,6 +13,8 @@ public NuGetPackageInfo(string id, string version, string? condition = null) Condition = condition; } + public NuGetPackageInfo WithCondition(string? condition) => new(Id, Version, condition); + public override bool Equals(object? obj) => Equals(obj as NuGetPackageInfo); public bool Equals(NuGetPackageInfo? other) { diff --git a/tests/CentralPackageManagementMigrator.Tests/Builders/ProjectBuilderTests.cs b/tests/CentralPackageManagementMigrator.Tests/Builders/ProjectBuilderTests.cs index fdc9810..65a936b 100644 --- a/tests/CentralPackageManagementMigrator.Tests/Builders/ProjectBuilderTests.cs +++ b/tests/CentralPackageManagementMigrator.Tests/Builders/ProjectBuilderTests.cs @@ -434,6 +434,92 @@ public void GetPackagesProjectSource_SamePackageInConditionalAndUnconditional_Bo Assert.Equal("8.0.0", conditional.Version); } + [Fact] + public void GetPackagesProjectSource_SingleTfNoItemGroupCondition_SynthesizesCondition() + { + const string csproj = """ + + + net8.0 + + + + + + """; + + var packages = GetPackagesProjectSource(csproj); + + Assert.Single(packages); + Assert.Equal("Newtonsoft.Json", packages[0].Id); + Assert.Equal("'$(TargetFramework)' == 'net8.0'", packages[0].Condition); + } + + [Fact] + public void GetPackagesProjectSource_SingleTfWithExistingCondition_MergesCondition() + { + const string csproj = """ + + + net8.0 + + + + + + """; + + var packages = GetPackagesProjectSource(csproj); + + Assert.Single(packages); + Assert.Equal("Newtonsoft.Json", packages[0].Id); + Assert.Equal("'$(Configuration)' == 'Debug' and '$(TargetFramework)' == 'net8.0'", packages[0].Condition); + } + + [Fact] + public void GetPackagesProjectSource_MultiTf_NoSyntheticCondition() + { + const string csproj = """ + + + net6.0;net8.0 + + + + + + """; + + var packages = GetPackagesProjectSource(csproj); + + Assert.Single(packages); + Assert.Equal("Newtonsoft.Json", packages[0].Id); + Assert.Equal("'$(TargetFramework)' == 'net6.0'", packages[0].Condition); + } + + [Fact] + public void GetPackagesProjectSource_MultiTfUnconditionalItemGroup_ExpandsPerTf() + { + const string csproj = """ + + + net6.0;net8.0 + + + + + + """; + + var packages = GetPackagesProjectSource(csproj); + + Assert.Equal(2, packages.Count); + Assert.Contains(packages, + p => p.Condition == "'$(TargetFramework)' == 'net6.0'" && p.Version == "13.0.3"); + Assert.Contains(packages, + p => p.Condition == "'$(TargetFramework)' == 'net8.0'" && p.Version == "13.0.3"); + } + [Fact] public void GetTargetFrameworks_SingleTargetFramework_ReturnsOne() { From dc114f297f589a1c2392fd123373f4e9a41f097e Mon Sep 17 00:00:00 2001 From: olivierus Date: Fri, 29 May 2026 07:50:20 +0200 Subject: [PATCH 6/6] feat: add e2e test --- .../IntegrationTests/EndToEndBuildTests.cs | 287 ++++++++++++++++++ 1 file changed, 287 insertions(+) create mode 100644 tests/CentralPackageManagementMigrator.Tests/IntegrationTests/EndToEndBuildTests.cs diff --git a/tests/CentralPackageManagementMigrator.Tests/IntegrationTests/EndToEndBuildTests.cs b/tests/CentralPackageManagementMigrator.Tests/IntegrationTests/EndToEndBuildTests.cs new file mode 100644 index 0000000..143cb12 --- /dev/null +++ b/tests/CentralPackageManagementMigrator.Tests/IntegrationTests/EndToEndBuildTests.cs @@ -0,0 +1,287 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text; +using Xunit; +using Xunit.Abstractions; + +namespace CentralPackageManagementMigrator.Tests.IntegrationTests; + +[Trait("Category", "Integration")] +public class EndToEndBuildTests : IDisposable +{ + private readonly string _testDir; + private readonly string _toolProjectPath; + private readonly ITestOutputHelper _output; + + public EndToEndBuildTests(ITestOutputHelper output) + { + _output = output; + _testDir = Path.Combine(Path.GetTempPath(), "CPM_E2E_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_testDir); + + var assemblyDir = Path.GetDirectoryName(typeof(EndToEndBuildTests).Assembly.Location)!; + _toolProjectPath = Path.GetFullPath( + Path.Combine(assemblyDir, "..", "..", "..", "..", "..", + "src", "CentralPackageManagementMigrator", "CentralPackageManagementMigrator.csproj")); + } + + public void Dispose() + { + if (Directory.Exists(_testDir)) + { + try { Directory.Delete(_testDir, true); } + catch { /* best effort cleanup */ } + } + } + + [Fact] + public void SingleProject_SingleTf_BuildSucceeds() + { + var projDir = CreateProject("ProjA", "net10.0", ("Newtonsoft.Json", "13.0.3")); + + AssertToolSucceeds(); + AssertPropsContains("Newtonsoft.Json", "13.0.3"); + AssertVersionRemoved(projDir, "Newtonsoft.Json"); + AssertRestoreAndBuild(projDir); + } + + [Fact] + public void TwoProjects_SameTf_BuildSucceeds() + { + var projA = CreateProject("ProjA", "net10.0", ("Newtonsoft.Json", "13.0.3")); + var projB = CreateProject("ProjB", "net10.0", ("Microsoft.Extensions.Logging.Abstractions", "10.0.0")); + + AssertToolSucceeds(); + AssertPropsContains("Newtonsoft.Json", "13.0.3"); + AssertPropsContains("Microsoft.Extensions.Logging.Abstractions", "10.0.0"); + AssertVersionRemoved(projA, "Newtonsoft.Json"); + AssertVersionRemoved(projB, "Microsoft.Extensions.Logging.Abstractions"); + AssertRestoreAndBuild(projA); + AssertRestoreAndBuild(projB); + } + + [Fact] + public void SingleProject_MultiTfUnconditional_PropsGenerated() + { + var projDir = CreateProject("ProjA", "net10.0;net9.0", ("Newtonsoft.Json", "13.0.3")); + + AssertToolSucceeds(); + AssertPropsContains("Newtonsoft.Json", "13.0.3"); + AssertVersionRemoved(projDir, "Newtonsoft.Json"); + } + + [Fact] + public void MixedSingleAndMultiTf_BuildSucceeds() + { + var projA = CreateProject("ProjA", "net10.0", ("Newtonsoft.Json", "13.0.3")); + CreateProject("ProjB", "net10.0;net9.0", ("Newtonsoft.Json", "13.0.3")); + + AssertToolSucceeds(); + AssertPropsContains("Newtonsoft.Json", "13.0.3"); + AssertVersionRemoved(projA, "Newtonsoft.Json"); + AssertRestoreAndBuild(projA); + } + + [Fact] + public void PreExistingConditionalItemGroup_BuildSucceeds() + { + var projDir = CreateConditionalProject("ProjA", "net10.0", + ("Newtonsoft.Json", "13.0.3"), + condition: "'$(TargetFramework)' == 'net10.0'"); + + AssertToolSucceeds(); + AssertPropsContains("Newtonsoft.Json", "13.0.3"); + AssertVersionRemoved(projDir, "Newtonsoft.Json"); + AssertRestoreAndBuild(projDir); + } + + [Fact] + public void VersionConflict_DifferentVersions_BuildSucceeds() + { + var projA = CreateProject("ProjA", "net10.0", ("Newtonsoft.Json", "12.0.3")); + var projB = CreateProject("ProjB", "net10.0", ("Newtonsoft.Json", "13.0.3")); + + AssertToolSucceeds(); + AssertPropsContains("Newtonsoft.Json", "13.0.3"); + AssertVersionRemoved(projA, "Newtonsoft.Json"); + AssertVersionRemoved(projB, "Newtonsoft.Json"); + AssertRestoreAndBuild(projA); + AssertRestoreAndBuild(projB); + } + + private string CreateProject(string name, string tfm, + params (string package, string version)[] packages) + { + var projDir = Path.Combine(_testDir, name); + Directory.CreateDirectory(projDir); + File.WriteAllText(Path.Combine(projDir, $"{name}.csproj"), BuildCsproj(tfm, packages)); + File.WriteAllText(Path.Combine(projDir, "Program.cs"), BuildProgram(packages)); + return projDir; + } + + private string CreateConditionalProject(string name, string tfm, + (string package, string version) package, + string condition) + { + var projDir = Path.Combine(_testDir, name); + Directory.CreateDirectory(projDir); + File.WriteAllText(Path.Combine(projDir, $"{name}.csproj"), + BuildCsproj(tfm, [package], itemGroupCondition: condition)); + File.WriteAllText(Path.Combine(projDir, "Program.cs"), BuildProgram([package])); + return projDir; + } + + private static string BuildCsproj(string tfm, + (string package, string version)[] packages, + string? itemGroupCondition = null) + { + var sb = new StringBuilder(); + sb.AppendLine(""); + sb.AppendLine(" "); + sb.AppendLine(" enable"); + sb.AppendLine(tfm.Contains(';') + ? $" {tfm}" + : $" {tfm}"); + sb.AppendLine(" Exe"); + sb.AppendLine(" "); + + if (itemGroupCondition is not null) + { + sb.AppendLine($" "); + } + else + { + sb.AppendLine(" "); + } + + foreach (var (package, version) in packages) + { + sb.AppendLine($" "); + } + + sb.AppendLine(" "); + sb.AppendLine(""); + return sb.ToString(); + } + + private static string BuildProgram((string package, string version)[] packages) + { + if (packages.Any(p => p.package == "Newtonsoft.Json")) + { + return """ + using Newtonsoft.Json; + var data = new { Value = 42 }; + var json = JsonConvert.SerializeObject(data); + Console.WriteLine(json); + """; + } + + return "Console.WriteLine(\"Hello, World!\");"; + } + + private void AssertToolSucceeds() + { + var exitCode = RunTool(); + Assert.Equal(0, exitCode); + Assert.True(File.Exists(Path.Combine(_testDir, "Directory.Packages.props"))); + } + + private int RunTool() + { + var psi = new ProcessStartInfo + { + FileName = "dotnet", + Arguments = $"run --project \"{_toolProjectPath}\" -- -p \"{_testDir}\" --verbosity Error", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }; + + using var process = new Process { StartInfo = psi }; + _output.WriteLine($"Running tool on {_testDir}"); + process.Start(); + + var stdoutTask = process.StandardOutput.ReadToEndAsync(); + var stderrTask = process.StandardError.ReadToEndAsync(); + + if (!process.WaitForExit((int)TimeSpan.FromMinutes(3).TotalMilliseconds)) + { + process.Kill(); + throw new TimeoutException("Tool process timed out after 3 minutes."); + } + + var stdout = stdoutTask.Result; + var stderr = stderrTask.Result; + + if (!string.IsNullOrWhiteSpace(stdout)) + _output.WriteLine($"STDOUT: {stdout}"); + if (!string.IsNullOrWhiteSpace(stderr)) + _output.WriteLine($"STDERR: {stderr}"); + + return process.ExitCode; + } + + private int RunDotnet(string command, string workingDir) + { + var psi = new ProcessStartInfo + { + FileName = "dotnet", + Arguments = command, + WorkingDirectory = workingDir, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }; + + using var process = new Process { StartInfo = psi }; + process.Start(); + + var stdoutTask = process.StandardOutput.ReadToEndAsync(); + var stderrTask = process.StandardError.ReadToEndAsync(); + + if (!process.WaitForExit((int)TimeSpan.FromMinutes(2).TotalMilliseconds)) + { + process.Kill(); + throw new TimeoutException("dotnet process timed out."); + } + + var stdout = stdoutTask.Result; + var stderr = stderrTask.Result; + + if (!string.IsNullOrWhiteSpace(stdout)) + _output.WriteLine($"STDOUT: {stdout}"); + if (!string.IsNullOrWhiteSpace(stderr)) + _output.WriteLine($"STDERR: {stderr}"); + + return process.ExitCode; + } + + private void AssertRestoreAndBuild(string projDir) + { + var csproj = Directory.GetFiles(projDir, "*.csproj").Single(); + + _output.WriteLine($"Restoring {csproj}..."); + var restoreExitCode = RunDotnet($"restore \"{csproj}\"", projDir); + Assert.Equal(0, restoreExitCode); + + _output.WriteLine($"Building {csproj}..."); + var buildExitCode = RunDotnet($"build \"{csproj}\" --no-restore", projDir); + Assert.Equal(0, buildExitCode); + } + + private void AssertPropsContains(string packageId, string version) + { + var props = File.ReadAllText(Path.Combine(_testDir, "Directory.Packages.props")); + Assert.Contains($"