File: Graph\GraphLoadedFromSolution_tests.cs
Web Access
Project: ..\..\..\src\Build.UnitTests\Microsoft.Build.Engine.UnitTests.csproj (Microsoft.Build.Engine.UnitTests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Build.BackEnd;
using Microsoft.Build.Collections;
using Microsoft.Build.Construction;
using Microsoft.Build.Engine.UnitTests;
using Microsoft.Build.Exceptions;
using Microsoft.Build.Execution;
using Microsoft.Build.Shared;
using Microsoft.Build.UnitTests;
using Shouldly;
using Xunit;
using Xunit.Abstractions;
using static Microsoft.Build.Graph.UnitTests.GraphTestingUtilities;
using static Microsoft.Build.UnitTests.Helpers;
 
#nullable disable
 
namespace Microsoft.Build.Graph.UnitTests
{
    public class GraphLoadedFromSolutionTests : IDisposable
    {
        private TestEnvironment _env;
 
        public GraphLoadedFromSolutionTests(ITestOutputHelper output)
        {
            _env = TestEnvironment.Create(output);
        }
 
        [Theory]
        [InlineData("1.sln", "2.sln")]
        [InlineData("1.sln", "2.proj")]
        public void ASolutionShouldBeTheSingleEntryPoint(params string[] files)
        {
            for (var i = 0; i < files.Length; i++)
            {
                files[i] = _env.CreateFile(files[i], string.Empty).Path;
            }
 
            var exception = Should.Throw<ArgumentException>(
                () =>
                {
                    new ProjectGraph(files);
                });
 
            exception.Message.ShouldContain("MSB4261");
        }
 
        [Fact]
        public void GraphConstructionFailsOnNonExistentSolution()
        {
            var exception = Should.Throw<InvalidProjectFileException>(
                () =>
                {
                    new ProjectGraph("nonExistent.sln");
                });
 
            exception.Message.ShouldContain("The project file could not be loaded. Could not find file");
        }
 
        [Fact]
        public void StaticGraphShouldNotSupportNestedSolutions()
        {
            var solutionFile = _env.CreateFile("solutionReference.sln", string.Empty).Path;
 
            var referenceToSolution = $@"<ItemGroup>
                                           <ProjectReference Include='{solutionFile}' />
                                       </ItemGroup>".Cleanup();
 
            var root = GraphTestingUtilities.CreateProjectFile(
                env: _env,
                projectNumber: 1,
                projectReferences: null,
                projectReferenceTargets: null,
                defaultTargets: null,
                extraContent: referenceToSolution);
 
            var aggException = Should.Throw<AggregateException>(() => new ProjectGraph(root.Path));
            aggException.InnerExceptions.ShouldHaveSingleItem();
 
            var exception = aggException.InnerExceptions[0].ShouldBeOfType<InvalidOperationException>();
            exception.Message.ShouldContain("MSB4263:");
        }
 
        public static IEnumerable<object[]> Graphs
        {
            get
            {
                yield return new object[]
                {
                    new Dictionary<int, int[]>()
                };
 
                yield return new object[]
                {
                    new Dictionary<int, int[]>
                    {
                        {1, null}
                    }
                };
 
                yield return new object[]
                {
                    new Dictionary<int, int[]>
                    {
                        {1, null},
                        {2, null}
                    }
                };
 
                yield return new object[]
                {
                    new Dictionary<int, int[]>
                    {
                        {1, new []{2}}
                    }
                };
 
                yield return new object[]
                {
                    new Dictionary<int, int[]>
                    {
                        {1, new []{2}},
                        {2, new []{3}}
                    }
                };
 
                yield return new object[]
                {
                    new Dictionary<int, int[]>
                    {
                        {1, new []{2, 3}}
                    }
                };
 
                yield return new object[]
                {
                    new Dictionary<int, int[]>
                    {
                        {1, new []{3, 2}},
                        {2, new []{3}}
                    }
                };
 
                yield return new object[]
                {
                    new Dictionary<int, int[]>
                    {
                        {1, new []{2, 3}},
                        {2, new []{4}},
                        {3, new []{4}}
                    }
                };
 
                yield return new object[]
                {
                    new Dictionary<int, int[]>
                    {
                        {1, new []{2, 3}},
                        {2, new []{4}},
                        {3, new []{4}},
                        {5, new []{3, 2}}
                    }
                };
            }
        }
 
        public static IEnumerable<object[]> GraphsWithUniformSolutionConfigurations
        {
            get
            {
                foreach (var graph in Graphs)
                {
                    foreach (
                        var currentSolutionConfigurationPlatform in SolutionFileBuilder.SolutionConfigurationPlatformsDefaults.Concat(new SolutionConfigurationInSolution[] { null }))
                    {
                        yield return new[]
                        {
                            graph.First(),
                            currentSolutionConfigurationPlatform,
                            SolutionFileBuilder.SolutionConfigurationPlatformsDefaults
                        };
                    }
                }
            }
        }
 
        [Theory(Skip = "hangs in CI, can't repro locally: https://github.com/dotnet/msbuild/issues/5453")]
        [MemberData(nameof(GraphsWithUniformSolutionConfigurations))]
        public void GraphConstructionCanLoadEntryPointsFromSolution(
            Dictionary<int, int[]> edges,
            SolutionConfigurationInSolution currentSolutionConfiguration,
            IReadOnlyCollection<SolutionConfigurationInSolution> solutionConfigurations)
        {
            AssertSolutionBasedGraph(edges, currentSolutionConfiguration, solutionConfigurations);
        }
 
        [Theory(Skip = "hangs in CI, can't repro locally: https://github.com/dotnet/msbuild/issues/5453")]
        [MemberData(nameof(GraphsWithUniformSolutionConfigurations))]
        public void SolutionBasedGraphCanMatchProjectSpecificConfigurations(
            Dictionary<int, int[]> edges,
            SolutionConfigurationInSolution currentSolutionConfiguration,
            IReadOnlyCollection<SolutionConfigurationInSolution> solutionConfigurations)
        {
            var graph = CreateProjectGraph(_env, edges);
 
            var projectSpecificConfigurations = graph.ProjectNodes.ToDictionary(
                node => GetProjectNumber(node).ToString(),
                n => solutionConfigurations.ToDictionary(
                    sc => sc,
                    sc => new ProjectConfigurationInSolution(
                        configurationName: $"{sc.ConfigurationName}_{GetProjectNumber(n)}",
                        platformName: $"{sc.PlatformName}_{GetProjectNumber(n)}",
                        includeInBuild: true)));
 
            AssertSolutionBasedGraph(edges, currentSolutionConfiguration, solutionConfigurations, projectSpecificConfigurations);
        }
 
        [Fact]
        public void SolutionParserIgnoresProjectConfigurationsThatDoNotFullyMatchAnySolutionConfiguration()
        {
            var solutionContents = new SolutionFileBuilder
            {
                Projects = new Dictionary<string, string>
                {
                    {"1", _env.CreateFile("1.csproj", string.Empty).Path}
                },
                SolutionConfigurationPlatforms = new[] { new SolutionConfigurationInSolution("Foo", "Bar") },
                ProjectConfigurations = new Dictionary<string, Dictionary<SolutionConfigurationInSolution, ProjectConfigurationInSolution>>
                {
                    {
                        "1", new Dictionary<SolutionConfigurationInSolution, ProjectConfigurationInSolution>
                        {
                            {
                                new SolutionConfigurationInSolution("NonMatchingConfiguration", "NonMatchingPlatform"),
                                new ProjectConfigurationInSolution("1a", "1b", true)
                            },
                            {
                                new SolutionConfigurationInSolution("Foo", "NonMatchingPlatform"),
                                new ProjectConfigurationInSolution("1c", "1d", true)
                            }
                        }
                    }
                }
            }.BuildSolution();
 
            var solutionFile = _env.CreateFile("solution.sln", solutionContents).Path;
 
            var graph = new ProjectGraph(solutionFile);
 
            graph.ProjectNodes.ShouldBeEmpty();
        }
 
        public static IEnumerable<object[]> SolutionOnlyDependenciesData
        {
            get
            {
                yield return new object[]
                {
                    new Dictionary<int, int[]> // graph nodes and ProjectReference edges
                    {
                        {1, null},
                        {2, null}
                    },
                    new[] {(1, 2) }, // solution only edges
                    false, // is there a cycle
                    false // solution edges overlap with graph edges
                };
 
                yield return new object[]
                {
                    new Dictionary<int, int[]>
                    {
                        {1, null},
                        {2, null}
                    },
                    new[] {(1, 2), (2, 1) },
                    true,
                    false
                };
 
                yield return new object[]
                {
                    new Dictionary<int, int[]>
                    {
                        {1, null},
                        {2, null},
                        {3, null},
                        {4, null},
                        {5, null}
                    },
                    new[] {(1, 2), (1, 3), (2, 4), (3,4), (4, 5) },
                    false,
                    false
                };
 
                yield return new object[]
                {
                    new Dictionary<int, int[]>
                    {
                        {1, null},
                        {2, null},
                        {3, null},
                        {4, null},
                        {5, null}
                    },
                    new[] {(1, 2), (1, 3), (2, 4), (3, 4), (4, 5), (2, 3) },
                    false,
                    false
                };
 
                yield return new object[]
                {
                    new Dictionary<int, int[]>
                    {
                        {1, null},
                        {2, null},
                        {3, null},
                        {4, null},
                        {5, null}
                    },
                    new[] {(1, 3), (2, 3), (3, 4), (3, 5), (5, 4), (2, 1) },
                    false,
                    false
                };
 
                yield return new object[]
                {
                    new Dictionary<int, int[]>
                    {
                        {1, new []{2}},
                    },
                    new[] {(1, 2) },
                    false,
                    true
                };
 
                yield return new object[]
                {
                    new Dictionary<int, int[]>
                    {
                        {1, new []{2}},
                    },
                    new[] {(1, 2), (1, 2) },
                    false,
                    true
                };
 
                yield return new object[]
                {
                    new Dictionary<int, int[]>
                    {
                        {1, new []{2}},
                    },
                    new[] {(2, 1) },
                    true,
                    false
                };
 
                yield return new object[]
                {
                    new Dictionary<int, int[]>
                    {
                        {1, new []{2, 3}},
                        {2, new []{4}},
                        {3, new []{4}},
                        {4, new []{5}},
                        {5, null}
                    },
                    new[] {(3, 2) },
                    false,
                    false
                };
 
                yield return new object[]
                {
                    new Dictionary<int, int[]>
                    {
                        {1, new []{2, 3}},
                        {2, new []{4}},
                        {3, new []{4}},
                        {4, new []{5}},
                        {5, null}
                    },
                    new[] {(1, 2), (1, 3), (3, 2), (1, 5) },
                    false,
                    true
                };
 
                yield return new object[]
                {
                    new Dictionary<int, int[]>
                    {
                        {1, new []{2, 3}},
                        {2, new []{4}},
                        {3, new []{4}},
                        {4, new []{5}},
                        {5, null}
                    },
                    new[] {(3, 2), (5, 3) },
                    true,
                    false
                };
 
                yield return new object[]
                {
                    new Dictionary<int, int[]>
                    {
                        {1, new []{2, 3}},
                        {2, new []{4}},
                        {3, new []{4}},
                        {4, new []{5}},
                        {5, null}
                    },
                    new[] {(5, 3) },
                    true,
                    false
                };
 
                yield return new object[]
                {
                    new Dictionary<int, int[]>
                    {
                        {1, new[] {2}},
                        {2, new[] {3}},
                        {3, new[] {4}},
                    },
                    new[] {(1,3), (2, 4) },
                    false,
                    false
                };
 
                yield return new object[]
                {
                    new Dictionary<int, int[]>
                    {
                        {1, new[] {2}},
                        {2, new[] {3}},
                        {3, new[] {4}},
                    },
                    new[] {(1,3), (2, 4), (1, 2), (2, 3), (3, 4) },
                    false,
                    true
                };
 
                yield return new object[]
                {
                    new Dictionary<int, int[]>
                    {
                        {1, new[] {2}},
                        {3, null},
                        {4, null}
                    },
                    new[] {(3, 2), (2, 4) },
                    false,
                    false
                };
 
                yield return new object[]
                {
                    new Dictionary<int, int[]>
                    {
                        {1, new[] {2}},
                        {3, null},
                        {4, null}
                    },
                    new[] {(3, 2), (2, 4), (4, 1) },
                    true,
                    false
                };
            }
        }
 
        [Theory]
        [MemberData(nameof(SolutionOnlyDependenciesData))]
        public void SolutionsCanInjectEdgesIntoTheProjectGraph(Dictionary<int, int[]> edges, (int, int)[] solutionDependencies, bool hasCycle, bool solutionEdgesOverlapGraphEdges)
        {
            // Use the same global properties as the solution would use so all ConfigurationMetadata objects would match on global properties.
            var graph = CreateProjectGraph(
                _env,
                edges,
                new Dictionary<string, string>()
                {
                    {"Configuration", "Debug"},
                    {"Platform", "AnyCPU"}
                });
 
            // Use ConfigurationMetadata because it is IEquatable, whereas ProjectGraphNode is not.
            var graphEdges = graph.TestOnly_Edges.TestOnly_AsConfigurationMetadata();
 
            var solutionContents =
                SolutionFileBuilder.FromGraph(
                    graph,
                    solutionDependencies: solutionDependencies.Select(dependency => (dependency.Item1.ToString(), dependency.Item2.ToString())).ToArray())
                    .BuildSolution();
            var solutionFile = _env.CreateFile("solution.sln", solutionContents).Path;
 
            Exception exception = null;
 
            ProjectGraph graphFromSolution = null;
 
            try
            {
                graphFromSolution = new ProjectGraph(solutionFile);
            }
            catch (Exception e)
            {
                exception = e;
            }
 
            if (hasCycle)
            {
                exception.ShouldNotBeNull();
                exception.Message.ShouldContain("MSB4251");
 
                return;
            }
 
            exception.ShouldBeNull();
 
            var graphFromSolutionEdges = graphFromSolution.TestOnly_Edges.TestOnly_AsConfigurationMetadata();
 
            // These are global properties added by GraphBuilder when building a solution
            HashSet<string> propertiesToIgnore = new(StringComparer.OrdinalIgnoreCase)
            {
                "CurrentSolutionConfigurationContents",
                "BuildingSolutionFile",
                "SolutionDir",
                "SolutionExt",
                "SolutionFileName",
                "SolutionName",
                SolutionProjectGenerator.SolutionPathPropertyName
            };
 
            // Solutions add these global properties
            foreach (string propertyToIgnore in propertiesToIgnore)
            {
                foreach ((ConfigurationMetadata, ConfigurationMetadata) graphFromSolutionEdge in graphFromSolutionEdges.Keys)
                {
                    graphFromSolutionEdge.Item1.GlobalProperties.ShouldContainKey(propertyToIgnore);
                    graphFromSolutionEdge.Item2.GlobalProperties.ShouldContainKey(propertyToIgnore);
                }
            }
 
            // Remove some properties for comparison purposes as we are comparing a graph created from a solution against the graph (without solution properties) used to make the solution.
            // This is done as a separate pass since some edges may be sharing an instance.
            foreach (string propertyToIgnore in propertiesToIgnore)
            {
                foreach ((ConfigurationMetadata, ConfigurationMetadata) graphFromSolutionEdge in graphFromSolutionEdges.Keys)
                {
                    graphFromSolutionEdge.Item1.GlobalProperties.Remove(propertyToIgnore);
                    graphFromSolutionEdge.Item2.GlobalProperties.Remove(propertyToIgnore);
                }
            }
 
            // Original edges get preserved.
            foreach (var graphEdge in graphEdges)
            {
                graphFromSolutionEdges.Keys.ShouldContain(graphEdge.Key);
            }
 
            // Solution edges get added. Assert each solution dependency is found within the graph edges
            var solutionOnlyEdges = graphFromSolutionEdges.Keys.Except(graphEdges.Keys).ToList();
 
            foreach (var solutionDependency in solutionDependencies)
            {
                if (!solutionEdgesOverlapGraphEdges)
                {
                    solutionOnlyEdges.ShouldContain(edge => EdgeCompliesWithSolutionDependency(edge, solutionDependency));
                }
 
                graphFromSolutionEdges.Keys.ShouldContain(edge => EdgeCompliesWithSolutionDependency(edge, solutionDependency));
 
                solutionOnlyEdges.RemoveAll(edge => EdgeCompliesWithSolutionDependency(edge, solutionDependency));
            }
 
            // no extra edges get added
            solutionOnlyEdges.ShouldBeEmpty();
        }
 
        [Fact]
        public void SolutionEdgesShouldNotOverwriteProjectReferenceEdges()
        {
            var solutionContents = SolutionFileBuilder.FromGraphEdges(
                _env,
                new Dictionary<int, int[]>()
                {
                    {1, new[] {2}}
                }).BuildSolution();
 
            var graph = new ProjectGraph(_env.CreateFile("solution.sln", solutionContents).Path);
 
            var edges = graph.TestOnly_Edges.TestOnly_AsConfigurationMetadata();
 
            edges.Count.ShouldBe(1);
 
            edges.First().Value.ItemType.ShouldBe(ItemTypeNames.ProjectReference);
        }
 
        [Fact]
        public void SolutionEdgesShouldNotOverwriteMultitargetingEdges()
        {
            var solutionContents = new SolutionFileBuilder
            {
                Projects = new Dictionary<string, string>
                {
                    {"1", GraphTestingUtilities.CreateProjectFile(_env, 1, new[] {2}).Path},
                    {"2", GraphTestingUtilities.CreateProjectFile(_env, 2, extraContent: MultitargetingSpecificationPropertyGroup).Path},
                    {"3", GraphTestingUtilities.CreateProjectFile(_env, 3, new[] {4}, extraContent: MultitargetingSpecificationPropertyGroup).Path},
                    {"4", GraphTestingUtilities.CreateProjectFile(_env, 4).Path}
                },
                SolutionDependencies = new[] { ("1", "2"), ("3", "4") }
            }.BuildSolution();
 
            var graph = new ProjectGraph(_env.CreateFile("solution.sln", solutionContents).Path);
 
            var edges = graph.TestOnly_Edges.TestOnly_AsConfigurationMetadata();
            edges.Count.ShouldBe(10);
 
            var node1 = GetFirstNodeWithProjectNumber(graph, 1);
            node1.ProjectReferences.Count.ShouldBe(3);
            node1.ProjectReferences.Count(r => GetProjectNumber(r) == 2).ShouldBe(3);
            GetOutgoingEdgeItemsFromNode(node1, edges).ShouldAllBe(edgeItem => !IsSolutionItemReference(edgeItem));
 
            var outerBuild3 = GetOuterBuild(graph, 3);
            outerBuild3.ProjectReferences.Count.ShouldBe(3);
            outerBuild3.ProjectReferences.Count(r => GetProjectNumber(r) == 3).ShouldBe(2);
            outerBuild3.ProjectReferences.Count(r => GetProjectNumber(r) == 4).ShouldBe(1);
 
            GetInnerBuilds(graph, 3).SelectMany(n => n.ProjectReferences).Count(r => GetProjectNumber(r) == 4).ShouldBe(2);
            GetInnerBuilds(graph, 3).SelectMany(n => GetIncomingEdgeItemsToNode(n, edges)).ShouldAllBe(edgeItem => !IsSolutionItemReference(edgeItem));
            GetInnerBuilds(graph, 3).SelectMany(n => GetOutgoingEdgeItemsFromNode(n, edges)).ShouldAllBe(edgeItem => !IsSolutionItemReference(edgeItem));
 
            IEnumerable<ProjectItemInstance> GetOutgoingEdgeItemsFromNode(ProjectGraphNode node, IReadOnlyDictionary<(ConfigurationMetadata, ConfigurationMetadata), ProjectItemInstance> edgeInfos)
            {
                return edgeInfos.Where(e => e.Key.Item1.Equals(node.ToConfigurationMetadata())).Select(e => e.Value);
            }
 
            IEnumerable<ProjectItemInstance> GetIncomingEdgeItemsToNode(ProjectGraphNode node, IReadOnlyDictionary<(ConfigurationMetadata, ConfigurationMetadata), ProjectItemInstance> edgeInfos)
            {
                return edgeInfos.Where(e => e.Key.Item2.Equals(node.ToConfigurationMetadata())).Select(e => e.Value);
            }
        }
 
        [Fact]
        public void GraphConstructionShouldThrowOnMissingSolutionDependencies()
        {
            var solutionContents = SolutionFileBuilder.FromGraphEdges(
                _env,
                new Dictionary<int, int[]> { { 1, null }, { 2, null } },
                new[] { ("1", new[] { Guid.NewGuid().ToString("B") }) }).BuildSolution();
 
            var solutionFile = _env.CreateFile(
                "solution.sln",
                solutionContents)
                .Path;
 
            var exception = Should.Throw<InvalidProjectFileException>(
                () =>
                {
                    new ProjectGraph(solutionFile);
                });
 
            exception.Message.ShouldContain("but a project with this GUID was not found in the .SLN file");
        }
 
        private static bool IsSolutionItemReference(ProjectItemInstance edgeItem)
        {
            return edgeItem.ItemType == GraphBuilder.SolutionItemReference;
        }
 
        private static bool EdgeCompliesWithSolutionDependency((ConfigurationMetadata, ConfigurationMetadata) edge, (int, int) solutionDependency)
        {
            return GetProjectNumber(edge.Item1) == solutionDependency.Item1 && GetProjectNumber(edge.Item2) == solutionDependency.Item2;
        }
 
        private void AssertSolutionBasedGraph(
            Dictionary<int, int[]> edges,
            SolutionConfigurationInSolution currentSolutionConfiguration,
            IReadOnlyCollection<SolutionConfigurationInSolution> solutionConfigurations,
            Dictionary<string, Dictionary<SolutionConfigurationInSolution, ProjectConfigurationInSolution>> projectConfigurations = null)
        {
            var graph = CreateProjectGraph(_env, edges);
            var graphEdges = graph.TestOnly_Edges.TestOnly_AsConfigurationMetadata();
 
            var solutionFileBuilder = SolutionFileBuilder.FromGraph(graph, projectConfigurations);
 
            var solutionContents = solutionFileBuilder.BuildSolution();
 
            var solutionPath = _env.CreateFile("TheSolution.sln", solutionContents).Path;
            var globalProperties = currentSolutionConfiguration != null
                ? new Dictionary<string, string>
                {
                    // Intentionally use mismatched casing to ensure it's properly normalized.
                    ["Configuration"] = currentSolutionConfiguration.ConfigurationName.ToUpperInvariant(),
                    ["Platform"] = currentSolutionConfiguration.PlatformName.ToUpperInvariant()
                }
                : new Dictionary<string, string>();
 
            var graphFromSolution = new ProjectGraph(
                new ProjectGraphEntryPoint(
                    solutionPath,
                    globalProperties),
                _env.CreateProjectCollection().Collection);
 
            // Exactly 1 node per project
            graph.ProjectNodes.Count.ShouldBe(graph.ProjectNodes.Select(GetProjectPath).Distinct().Count());
 
            // in the solution, all nodes are entry points
            graphFromSolution.EntryPointNodes.Select(GetProjectPath)
                .ShouldBeSetEquivalentTo(graph.ProjectNodes.Select(GetProjectPath));
 
            if (projectConfigurations == null || graphFromSolution.ProjectNodes.All(n => n.ProjectReferences.Count == 0))
            {
                graphFromSolution.GraphRoots.Select(GetProjectPath)
                    .ShouldBeSameIgnoringOrder(graph.GraphRoots.Select(GetProjectPath));
 
                graphFromSolution.ProjectNodes.Select(GetProjectPath)
                    .ShouldBeSameIgnoringOrder(graph.ProjectNodes.Select(GetProjectPath));
            }
 
            var expectedCurrentConfiguration = currentSolutionConfiguration ?? solutionConfigurations.First();
            var actualProjectConfigurations = projectConfigurations ?? solutionFileBuilder.ProjectConfigurations;
 
            foreach (var node in graphFromSolution.ProjectNodes)
            {
                var expectedProjectConfiguration = actualProjectConfigurations[GetProjectNumber(node).ToString()][expectedCurrentConfiguration];
                GetConfiguration(node).ShouldBe(expectedProjectConfiguration.ConfigurationName);
                GetPlatform(node).ShouldBe(expectedProjectConfiguration.PlatformName);
            }
        }
 
        private static string GetConfiguration(ProjectGraphNode node)
        {
            return node.ProjectInstance.GlobalProperties["Configuration"];
        }
 
        private static string GetPlatform(ProjectGraphNode node)
        {
            return node.ProjectInstance.GlobalProperties["Platform"];
        }
 
        public void Dispose()
        {
            _env.Dispose();
        }
    }
}