File: BackEnd\BatchingEngine_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.IO;
using System.Xml;
using Microsoft.Build.BackEnd;
using Microsoft.Build.Collections;
using Microsoft.Build.Engine.UnitTests;
using Microsoft.Build.Evaluation;
using Microsoft.Build.Execution;
using Microsoft.Build.Framework;
using Microsoft.Build.Shared.FileSystem;
using Xunit;
using InvalidProjectFileException = Microsoft.Build.Exceptions.InvalidProjectFileException;
using ProjectItemInstanceFactory = Microsoft.Build.Execution.ProjectItemInstance.TaskItem.ProjectItemInstanceFactory;
 
#nullable disable
 
namespace Microsoft.Build.UnitTests.BackEnd
{
    public class BatchingEngine_Tests
    {
        [Fact]
        public void GetBuckets()
        {
            ProjectInstance project = ProjectHelpers.CreateEmptyProjectInstance();
            List<string> parameters = new List<string>();
            parameters.Add("@(File);$(unittests)");
            parameters.Add("$(obj)\\%(Filename).ext");
            parameters.Add("@(File->'%(extension)')");  // attributes in transforms don't affect batching
 
            ItemDictionary<ProjectItemInstance> itemsByType = new ItemDictionary<ProjectItemInstance>();
 
            IList<ProjectItemInstance> items = new List<ProjectItemInstance>();
            items.Add(new ProjectItemInstance(project, "File", "a.foo", project.FullPath));
            items.Add(new ProjectItemInstance(project, "File", "b.foo", project.FullPath));
            items.Add(new ProjectItemInstance(project, "File", "c.foo", project.FullPath));
            items.Add(new ProjectItemInstance(project, "File", "d.foo", project.FullPath));
            items.Add(new ProjectItemInstance(project, "File", "e.foo", project.FullPath));
            itemsByType.ImportItems(items);
 
            items = new List<ProjectItemInstance>();
            items.Add(new ProjectItemInstance(project, "Doc", "a.doc", project.FullPath));
            items.Add(new ProjectItemInstance(project, "Doc", "b.doc", project.FullPath));
            items.Add(new ProjectItemInstance(project, "Doc", "c.doc", project.FullPath));
            items.Add(new ProjectItemInstance(project, "Doc", "d.doc", project.FullPath));
            items.Add(new ProjectItemInstance(project, "Doc", "e.doc", project.FullPath));
            itemsByType.ImportItems(items);
 
            PropertyDictionary<ProjectPropertyInstance> properties = new PropertyDictionary<ProjectPropertyInstance>();
            properties.Set(ProjectPropertyInstance.Create("UnitTests", "unittests.foo"));
            properties.Set(ProjectPropertyInstance.Create("OBJ", "obj"));
 
            List<ItemBucket> buckets = BatchingEngine.PrepareBatchingBuckets(
                parameters,
                CreateLookup(itemsByType, properties),
                MockElementLocation.Instance,
                new TestLoggingContext(null!, new BuildEventContext(1, 2, 3, 4)));
 
            Assert.Equal(5, buckets.Count);
 
            foreach (ItemBucket bucket in buckets)
            {
                // non-batching data -- same for all buckets
                XmlAttribute tempXmlAttribute = (new XmlDocument()).CreateAttribute("attrib");
                tempXmlAttribute.Value = "'$(Obj)'=='obj'";
 
                Assert.True(ConditionEvaluator.EvaluateCondition(
                    tempXmlAttribute.Value,
                    ParserOptions.AllowAll,
                    bucket.Expander, ExpanderOptions.ExpandAll,
                    Directory.GetCurrentDirectory(),
                    MockElementLocation.Instance,
                    FileSystems.Default,
                    new TestLoggingContext(null!, new BuildEventContext(1, 2, 3, 4))));
                Assert.Equal("a.doc;b.doc;c.doc;d.doc;e.doc", bucket.Expander.ExpandIntoStringAndUnescape("@(doc)", ExpanderOptions.ExpandItems, MockElementLocation.Instance));
                Assert.Equal("unittests.foo", bucket.Expander.ExpandIntoStringAndUnescape("$(bogus)$(UNITTESTS)", ExpanderOptions.ExpandPropertiesAndMetadata, MockElementLocation.Instance));
            }
 
            Assert.Equal("a.foo", buckets[0].Expander.ExpandIntoStringAndUnescape("@(File)", ExpanderOptions.ExpandItems, MockElementLocation.Instance));
            Assert.Equal(".foo", buckets[0].Expander.ExpandIntoStringAndUnescape("@(File->'%(Extension)')", ExpanderOptions.ExpandItems, MockElementLocation.Instance));
            Assert.Equal("obj\\a.ext", buckets[0].Expander.ExpandIntoStringAndUnescape("$(obj)\\%(Filename).ext", ExpanderOptions.ExpandPropertiesAndMetadata, MockElementLocation.Instance));
 
            // we weren't batching on this attribute, so it has no value
            Assert.Equal(String.Empty, buckets[0].Expander.ExpandIntoStringAndUnescape("%(Extension)", ExpanderOptions.ExpandAll, MockElementLocation.Instance));
 
            ProjectItemInstanceFactory factory = new ProjectItemInstanceFactory(project, "i");
            items = buckets[0].Expander.ExpandIntoItemsLeaveEscaped("@(file)", factory, ExpanderOptions.ExpandItems, MockElementLocation.Instance);
            Assert.NotNull(items);
            Assert.Single(items);
 
            int invalidProjectFileExceptions = 0;
            try
            {
                // This should throw because we don't allow item lists to be concatenated
                // with other strings.
                bool throwAway;
                items = buckets[0].Expander.ExpandSingleItemVectorExpressionIntoItems("@(file)$(unitests)", factory, ExpanderOptions.ExpandItems, false /* no nulls */, out throwAway, MockElementLocation.Instance);
            }
            catch (InvalidProjectFileException ex)
            {
                // check we don't lose error codes from IPFE's during build
                Assert.Equal("MSB4012", ex.ErrorCode);
                invalidProjectFileExceptions++;
            }
 
            // We do allow separators in item vectors, this results in an item group with a single flattened item
            items = buckets[0].Expander.ExpandIntoItemsLeaveEscaped("@(file, ',')", factory, ExpanderOptions.ExpandItems, MockElementLocation.Instance);
            Assert.NotNull(items);
            Assert.Single(items);
            Assert.Equal("a.foo", items[0].EvaluatedInclude);
 
            Assert.Equal(1, invalidProjectFileExceptions);
        }
 
        /// <summary>
        /// Tests the real simple case of using an unqualified metadata reference %(Culture),
        /// where there are only two items and both of them have a value for Culture, but they
        /// have different values.
        /// </summary>
        [Fact]
        public void ValidUnqualifiedMetadataReference()
        {
            ProjectInstance project = ProjectHelpers.CreateEmptyProjectInstance();
            List<string> parameters = new List<string>();
            parameters.Add("@(File)");
            parameters.Add("%(Culture)");
 
            ItemDictionary<ProjectItemInstance> itemsByType = new ItemDictionary<ProjectItemInstance>();
 
            List<ProjectItemInstance> items = new List<ProjectItemInstance>();
 
            ProjectItemInstance a = new ProjectItemInstance(project, "File", "a.foo", project.FullPath);
            ProjectItemInstance b = new ProjectItemInstance(project, "File", "b.foo", project.FullPath);
            a.SetMetadata("Culture", "fr-fr");
            b.SetMetadata("Culture", "en-en");
            items.Add(a);
            items.Add(b);
            itemsByType.ImportItems(items);
 
            PropertyDictionary<ProjectPropertyInstance> properties = new PropertyDictionary<ProjectPropertyInstance>();
 
            List<ItemBucket> buckets = BatchingEngine.PrepareBatchingBuckets(
                parameters,
                CreateLookup(itemsByType, properties),
                null,
                new TestLoggingContext(null!, new BuildEventContext(1, 2, 3, 4)));
            Assert.Equal(2, buckets.Count);
        }
 
        /// <summary>
        /// Tests the case where an unqualified metadata reference is used illegally.
        /// It's illegal because not all of the items consumed contain a value for
        /// that metadata.
        /// </summary>
        [Fact]
        public void InvalidUnqualifiedMetadataReference()
        {
            Assert.Throws<InvalidProjectFileException>(() =>
            {
                ProjectInstance project = ProjectHelpers.CreateEmptyProjectInstance();
                List<string> parameters = new List<string>();
                parameters.Add("@(File)");
                parameters.Add("%(Culture)");
 
                ItemDictionary<ProjectItemInstance> itemsByType = new ItemDictionary<ProjectItemInstance>();
 
                List<ProjectItemInstance> items = new List<ProjectItemInstance>();
 
                ProjectItemInstance a = new ProjectItemInstance(project, "File", "a.foo", project.FullPath);
                items.Add(a);
                ProjectItemInstance b = new ProjectItemInstance(project, "File", "b.foo", project.FullPath);
                items.Add(b);
                a.SetMetadata("Culture", "fr-fr");
                itemsByType.ImportItems(items);
 
                PropertyDictionary<ProjectPropertyInstance> properties = new PropertyDictionary<ProjectPropertyInstance>();
 
                // This is expected to throw because not all items contain a value for metadata "Culture".
                // Only a.foo has a Culture metadata.  b.foo does not.
                BatchingEngine.PrepareBatchingBuckets(
                    parameters,
                    CreateLookup(itemsByType, properties),
                    MockElementLocation.Instance,
                    new TestLoggingContext(null!, new BuildEventContext(1, 2, 3, 4)));
            });
        }
        /// <summary>
        /// Tests the case where an unqualified metadata reference is used illegally.
        /// It's illegal because not all of the items consumed contain a value for
        /// that metadata.
        /// </summary>
        [Fact]
        public void NoItemsConsumed()
        {
            Assert.Throws<InvalidProjectFileException>(() =>
            {
                List<string> parameters = new List<string>();
                parameters.Add("$(File)");
                parameters.Add("%(Culture)");
 
                ItemDictionary<ProjectItemInstance> itemsByType = new ItemDictionary<ProjectItemInstance>();
                PropertyDictionary<ProjectPropertyInstance> properties = new PropertyDictionary<ProjectPropertyInstance>();
 
                // This is expected to throw because we have no idea what item list %(Culture) refers to.
                BatchingEngine.PrepareBatchingBuckets(
                    parameters,
                    CreateLookup(itemsByType, properties),
                    MockElementLocation.Instance,
                    new TestLoggingContext(null!, new BuildEventContext(1, 2, 3, 4)));
            });
        }
        /// <summary>
        /// Missing unittest found by mutation testing.
        /// REASON TEST WASN'T ORIGINALLY PRESENT: Missed test.
        ///
        /// This test ensures that two items with duplicate attributes end up in exactly one batching
        /// bucket.
        /// </summary>
        [Fact]
        public void Regress_Mutation_DuplicateBatchingBucketsAreFoldedTogether()
        {
            ProjectInstance project = ProjectHelpers.CreateEmptyProjectInstance();
            List<string> parameters = new List<string>();
            parameters.Add("%(File.Culture)");
 
            ItemDictionary<ProjectItemInstance> itemsByType = new ItemDictionary<ProjectItemInstance>();
 
            List<ProjectItemInstance> items = new List<ProjectItemInstance>();
            items.Add(new ProjectItemInstance(project, "File", "a.foo", project.FullPath));
            items.Add(new ProjectItemInstance(project, "File", "b.foo", project.FullPath)); // Need at least two items for this test case to ensure multiple buckets might be possible
            itemsByType.ImportItems(items);
 
            PropertyDictionary<ProjectPropertyInstance> properties = new PropertyDictionary<ProjectPropertyInstance>();
 
            List<ItemBucket> buckets = BatchingEngine.PrepareBatchingBuckets(
                parameters,
                CreateLookup(itemsByType, properties),
                null,
                new TestLoggingContext(null!, new BuildEventContext(1, 2, 3, 4)));
 
            // If duplicate buckets have been folded correctly, then there will be exactly one bucket here
            // containing both a.foo and b.foo.
            Assert.Single(buckets);
        }
 
        [Fact]
        public void Simple()
        {
            string content = @"
                <Project ToolsVersion=""msbuilddefaulttoolsversion"">
 
                    <ItemGroup>
                        <AToB Include=""a;b""/>
                    </ItemGroup>
 
                    <Target Name=""Build"">
                        <CreateItem Include=""%(AToB.Identity)"">
                            <Output ItemName=""AToBBatched"" TaskParameter=""Include""/>
                        </CreateItem>
                        <Message Text=""[AToBBatched: @(AToBBatched)]""/>
                    </Target>
 
                </Project>
                ";
 
            MockLogger log = Helpers.BuildProjectWithNewOMExpectSuccess(content);
 
            log.AssertLogContains("[AToBBatched: a;b]");
        }
 
 
        /// <summary>
        /// When removing an item in a target which is batched and called by call target there was an exception thrown
        /// due to us adding the same item instance to the remove item lists when merging the lookups between the two batches.
        /// The fix was to not add the item to the remove list if it already exists.
        /// </summary>
        [Fact]
        public void Regress72803()
        {
            string content = @"
                <Project DefaultTargets=""ReleaseBuild"">
                    <ItemGroup>
                        <Environments Include=""dev"" />
                        <Environments Include=""prod"" />
                        <ItemsToZip Include=""1"" />
                     </ItemGroup>
                    <Target Name=""ReleaseBuild"">
                    <CallTarget Targets=""MakeAppPackage;MakeDbPackage""/>
                    </Target>
                    <Target Name=""MakeAppPackage"" Outputs=""%(Environments.Identity)"">
                        <ItemGroup>
                            <ItemsToZip Include=""%(Environments.Identity).msi"" />
                        </ItemGroup>
                    </Target>
                    <Target Name=""MakeDbPackage""  Outputs=""%(Environments.Identity)"">
                        <Message Text=""Item Before:%(Environments.Identity) @(ItemsToZip)"" />
                        <ItemGroup>
                             <ItemsToZip Remove=""@(ItemsToZip)"" />
                        </ItemGroup>
                        <Message Text=""Item After:%(Environments.Identity) @(ItemsToZip)"" Condition=""'@(ItemsToZip)' != ''"" />
                    </Target>
                </Project>
               ";
 
            MockLogger log = Helpers.BuildProjectWithNewOMExpectSuccess(content);
 
            log.AssertLogContains("Item Before:dev 1");
            log.AssertLogContains("Item Before:prod 1");
            log.AssertLogDoesntContain("Item After:dev 1");
            log.AssertLogDoesntContain("Item After:prod 1");
        }
 
        /// <summary>
        /// Regress a bug where batching over an item list seemed to have
        /// items for that list even in buckets where there should be none, because
        /// it was batching over metadata that only other list/s had.
        /// </summary>
        [Fact]
        public void BucketsWithEmptyListForBatchedItemList()
        {
            string content = @"
 <Project ToolsVersion=""msbuilddefaulttoolsversion"">
  <ItemGroup>
    <i Include=""b""/>
    <j Include=""a"">
        <k>x</k>
    </j>
  </ItemGroup>
 
  <Target Name=""t"">
    <ItemGroup>
      <Obj Condition=""'%(j.k)'==''"" Include=""@(j->'%(Filename).obj');%(i.foo)""/>
    </ItemGroup>
    <Message Text=""@(Obj)"" />
  </Target>
</Project>
                ";
 
            MockLogger log = Helpers.BuildProjectWithNewOMExpectSuccess(content);
 
            log.AssertLogDoesntContain("a.obj");
        }
 
        /// <summary>
        /// Bug for Targets instead of Tasks.
        /// </summary>
        [Fact]
        public void BucketsWithEmptyListForTargetBatchedItemList()
        {
            string content = @"
<Project ToolsVersion=""msbuilddefaulttoolsversion"">
    <ItemGroup>
        <a Include=""a1""/>
        <b Include=""b1""/>
    </ItemGroup>
    <Target Name=""t""  Outputs=""%(a.Identity)%(b.identity)"">
        <Message Text=""[a=@(a) b=@(b)]"" />
    </Target>
</Project>
                ";
 
            MockLogger log = Helpers.BuildProjectWithNewOMExpectSuccess(content);
 
            log.AssertLogContains("[a=a1 b=]");
            log.AssertLogContains("[a= b=b1]");
        }
 
        /// <summary>
        /// A batching target that has no outputs should still run.
        /// This is how we shipped before, although Jay pointed out it's odd.
        /// </summary>
        [Fact]
        public void BatchOnEmptyOutput()
        {
            string content = @"
         <Project ToolsVersion=""msbuilddefaulttoolsversion"">
            <ItemGroup>
              <File Include=""$(foo)"" />
            </ItemGroup>
            <!-- Should not run as the single batch has no outputs -->
            <Target Name=""b""  Outputs=""%(File.Identity)""><Message Text=""[@(File)]"" /></Target>
            <Target Name=""a"" DependsOnTargets=""b"">
               <Message Text=""[a]"" />
            </Target>
         </Project>
                ";
 
            MockLogger log = Helpers.BuildProjectWithNewOMExpectSuccess(content);
 
            log.AssertLogContains("[]");
        }
 
        /// <summary>
        /// Every batch should get its own new task object.
        /// We verify this by using the Warning class. If the same object is being reused,
        /// the second warning would have the code from the first use of the task.
        /// </summary>
        [Fact]
        public void EachBatchGetsASeparateTaskObject()
        {
            string content = @"
                <Project ToolsVersion=""msbuilddefaulttoolsversion"">
                  <ItemGroup>
                    <i Include=""i1"">
                      <Code>high</Code>
                    </i>
                    <i Include=""i2""/>
                  </ItemGroup>
 
                  <Target Name=""t"">
                    <Warning Text=""@(i)"" Code=""%(i.Code)""/>
                  </Target>
                </Project>";
 
            MockLogger log = Helpers.BuildProjectWithNewOMExpectSuccess(content);
 
            Assert.Equal("high", log.Warnings[0].Code);
            Assert.Null(log.Warnings[1].Code);
        }
 
 
        /// <summary>
        /// It is important that the batching engine invokes the different batches in the same
        /// order as the items are declared in the project, especially when batching is simply
        /// being used as a "for loop".
        /// </summary>
        [Fact]
        public void BatcherPreservesItemOrderWithinASingleItemList()
        {
            string content = @"
                <Project ToolsVersion=""msbuilddefaulttoolsversion"">
 
                    <ItemGroup>
                        <AToZ Include=""a;b;c;d;e;f;g;h;i;j;k;l;m;n;o;p;q;r;s;t;u;v;w;x;y;z""/>
                        <ZToA Include=""z;y;x;w;v;u;t;s;r;q;p;o;n;m;l;k;j;i;h;g;f;e;d;c;b;a""/>
                    </ItemGroup>
 
                    <Target Name=""Build"">
                        <CreateItem Include=""%(AToZ.Identity)"">
                            <Output ItemName=""AToZBatched"" TaskParameter=""Include""/>
                        </CreateItem>
                        <CreateItem Include=""%(ZToA.Identity)"">
                            <Output ItemName=""ZToABatched"" TaskParameter=""Include""/>
                        </CreateItem>
                        <Message Text=""AToZBatched: @(AToZBatched)""/>
                        <Message Text=""ZToABatched: @(ZToABatched)""/>
                    </Target>
 
                </Project>
                ";
 
            MockLogger log = Helpers.BuildProjectWithNewOMExpectSuccess(content);
 
            log.AssertLogContains("AToZBatched: a;b;c;d;e;f;g;h;i;j;k;l;m;n;o;p;q;r;s;t;u;v;w;x;y;z");
            log.AssertLogContains("ZToABatched: z;y;x;w;v;u;t;s;r;q;p;o;n;m;l;k;j;i;h;g;f;e;d;c;b;a");
        }
 
        /// <summary>
        /// Undefined and empty metadata values should not be distinguished when bucketing.
        /// This is the same as previously shipped.
        /// </summary>
        [Fact]
        public void UndefinedAndEmptyMetadataValues()
        {
            string content = @"
                <Project ToolsVersion='msbuilddefaulttoolsversion'>
                    <ItemGroup>
                        <i Include='i1'/>
                        <i Include='i2'>
                            <m></m>
                        </i>
                        <i Include='i3'>
                            <m>m1</m>
                        </i>
                    </ItemGroup>
 
                    <Target Name='Build'>
                        <Message Text='[@(i) %(i.m)]'/>
                    </Target>
 
                </Project>
                ";
 
            using ProjectFromString projectFromString = new(ObjectModelHelpers.CleanupFileContents(content));
            Project project = projectFromString.Project;
            MockLogger logger = new MockLogger();
            project.Build(logger);
 
            logger.AssertLogContains("[i1;i2 ]", "[i3 m1]");
        }
 
        /// <summary>
        /// This is a regression test for https://github.com/dotnet/msbuild/issues/10180.
        /// </summary>
        [Fact]
        public void HandlesEarlyExitFromTargetBatching()
        {
            string content = @"
                <Project>
                    <ItemGroup>
                        <Example Include='Item1'>
                            <Color>Blue</Color>
                        </Example>
                        <Example Include='Item2'>
                            <Color>Red</Color>
                        </Example>
                    </ItemGroup>
 
                    <Target Name='Build'
                        Inputs='@(Example)'
                        Outputs='%(Color)\MyFile.txt'>
                        <NonExistentTask
                            Text = '@(Example)'
                            Output = '%(Color)\MyFile.txt'/>
                    </Target>
                </Project>
                ";
            using ProjectFromString projectFromString = new(content);
            Project project = projectFromString.Project;
            MockLogger logger = new MockLogger();
            project.Build(logger);
 
            // Build should fail with error MSB4036: The "NonExistentTask" task was not found.
            logger.AssertLogContains("MSB4036");
        }
 
        private static Lookup CreateLookup(ItemDictionary<ProjectItemInstance> itemsByType, PropertyDictionary<ProjectPropertyInstance> properties)
        {
            return new Lookup(itemsByType, properties);
        }
    }
}