File: WriteLinesToFile_Tests.cs
Web Access
Project: ..\..\..\src\Tasks.UnitTests\Microsoft.Build.Tasks.UnitTests.csproj (Microsoft.Build.Tasks.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.IO;
using System.Linq;
using System.Reflection.Metadata;
using Microsoft.Build.Evaluation;
using Microsoft.Build.Execution;
using Microsoft.Build.Framework;
using Microsoft.Build.Logging;
using Microsoft.Build.Shared;
using Microsoft.Build.UnitTests;
using Microsoft.Build.Utilities;
using Shouldly;
using Xunit;
using Xunit.Abstractions;
 
#nullable disable
 
namespace Microsoft.Build.Tasks.UnitTests
{
    public sealed class WriteLinesToFile_Tests
    {
        private readonly ITestOutputHelper _output;
 
        public WriteLinesToFile_Tests(ITestOutputHelper output)
        {
            _output = output;
        }
 
        /// <summary>
        /// Invalid encoding
        /// </summary>
        [Fact]
        public void InvalidEncoding()
        {
            var a = new WriteLinesToFile
            {
                BuildEngine = new MockEngine(_output),
                TaskEnvironment = TaskEnvironmentHelper.CreateForTest(),
                Encoding = "||invalid||",
                File = new TaskItem("c:\\" + Guid.NewGuid().ToString()),
                Lines = new TaskItem[] { new TaskItem("x") }
            };
 
            Assert.False(a.Execute());
            ((MockEngine)a.BuildEngine).AssertLogContains("MSB3098");
            Assert.False(File.Exists(a.File.ItemSpec));
        }
 
        /// <summary>
        /// Reading blank lines from a file should be ignored.
        /// </summary>
        [Fact]
        public void Encoding()
        {
            var file = FileUtilities.GetTemporaryFile();
            try
            {
                // Write default encoding: UTF8
                var a = new WriteLinesToFile
                {
                    BuildEngine = new MockEngine(_output),
                    TaskEnvironment = TaskEnvironmentHelper.CreateForTest(),
                    File = new TaskItem(file),
                    Lines = new ITaskItem[] { new TaskItem("\uBDEA") }
                };
                Assert.True(a.Execute());
 
                var r = new ReadLinesFromFile
                {
                    File = new TaskItem(file),
                    TaskEnvironment = TaskEnvironmentHelper.CreateForTest()
                };
                Assert.True(r.Execute());
 
                Assert.Equal("\uBDEA", r.Lines[0].ItemSpec);
 
                File.Delete(file);
 
                // Write ANSI .. that won't work!
                a = new WriteLinesToFile
                {
                    BuildEngine = new MockEngine(_output),
                    TaskEnvironment = TaskEnvironmentHelper.CreateForTest(),
                    File = new TaskItem(file),
                    Lines = new ITaskItem[] { new TaskItem("\uBDEA") },
                    Encoding = "ASCII"
                };
                Assert.True(a.Execute());
 
                // Read the line from the file.
                r = new ReadLinesFromFile
                {
                    File = new TaskItem(file),
                    TaskEnvironment = TaskEnvironmentHelper.CreateForTest()
                };
                Assert.True(r.Execute());
 
                Assert.NotEqual("\uBDEA", r.Lines[0].ItemSpec);
            }
            finally
            {
                File.Delete(file);
            }
        }
 
        [Fact]
        public void WriteLinesWriteOnlyWhenDifferentTest()
        {
            var file = FileUtilities.GetTemporaryFile();
            try
            {
                // Write an initial file.
                var a = new WriteLinesToFile
                {
                    Overwrite = true,
                    BuildEngine = new MockEngine(_output),
                    TaskEnvironment = TaskEnvironmentHelper.CreateForTest(),
                    File = new TaskItem(file),
                    WriteOnlyWhenDifferent = true,
                    Lines = new ITaskItem[] { new TaskItem("File contents1") }
                };
 
                a.Execute().ShouldBeTrue();
 
                // Verify contents
                var r = new ReadLinesFromFile { File = new TaskItem(file), TaskEnvironment = TaskEnvironmentHelper.CreateForTest() };
                r.Execute().ShouldBeTrue();
                r.Lines[0].ItemSpec.ShouldBe("File contents1");
 
                var writeTime = DateTime.Now.AddHours(-1);
 
                File.SetLastWriteTime(file, writeTime);
 
                // Write the same contents to the file, timestamps should match.
                var a2 = new WriteLinesToFile
                {
                    Overwrite = true,
                    BuildEngine = new MockEngine(_output),
                    TaskEnvironment = TaskEnvironmentHelper.CreateForTest(),
                    File = new TaskItem(file),
                    WriteOnlyWhenDifferent = true,
                    Lines = new ITaskItem[] { new TaskItem("File contents1") }
                };
                a2.Execute().ShouldBeTrue();
                File.GetLastWriteTime(file).ShouldBe(writeTime, tolerance: TimeSpan.FromSeconds(1));
 
                // Write different contents to the file, last write time should differ.
                var a3 = new WriteLinesToFile
                {
                    Overwrite = true,
                    BuildEngine = new MockEngine(_output),
                    TaskEnvironment = TaskEnvironmentHelper.CreateForTest(),
                    File = new TaskItem(file),
                    WriteOnlyWhenDifferent = true,
                    Lines = new ITaskItem[] { new TaskItem("File contents2") }
                };
 
                a3.Execute().ShouldBeTrue();
                File.GetLastWriteTime(file).ShouldBeGreaterThan(writeTime.AddSeconds(1));
            }
            finally
            {
                File.Delete(file);
            }
        }
 
        [Fact]
        public void RedundantParametersAreLogged()
        {
            using TestEnvironment testEnv = TestEnvironment.Create(_output);
 
            MockEngine engine = new(_output);
 
            string file = testEnv.ExpectFile().Path;
 
            WriteLinesToFile task = new()
            {
                BuildEngine = engine,
                TaskEnvironment = TaskEnvironmentHelper.CreateForTest(),
                File = new TaskItem(file),
                Lines = new ITaskItem[] { new TaskItem($"{nameof(RedundantParametersAreLogged)} Test") },
                WriteOnlyWhenDifferent = true,
                Overwrite = false,
            };
 
            task.Execute().ShouldBeTrue();
            engine.AssertLogContainsMessageFromResource(AssemblyResources.GetString, "WriteLinesToFile.UnusedWriteOnlyWhenDifferent", file);
        }
 
        /// <summary>
        /// Question WriteLines to return false when a write will be required.
        /// </summary>
        [Fact]
        public void QuestionWriteLinesWriteOnlyWhenDifferentTest()
        {
            var file = FileUtilities.GetTemporaryFile();
            try
            {
                // Write an initial file.
                var a = new WriteLinesToFile
                {
                    Overwrite = true,
                    BuildEngine = new MockEngine(_output),
                    TaskEnvironment = TaskEnvironmentHelper.CreateForTest(),
                    File = new TaskItem(file),
                    WriteOnlyWhenDifferent = true,
                    Lines = new ITaskItem[] { new TaskItem("File contents1") }
                };
 
                a.Execute().ShouldBeTrue();
 
                // Verify contents
                var r = new ReadLinesFromFile { File = new TaskItem(file), TaskEnvironment = TaskEnvironmentHelper.CreateForTest() };
                r.Execute().ShouldBeTrue();
                r.Lines[0].ItemSpec.ShouldBe("File contents1");
 
                var writeTime = DateTime.Now.AddHours(-1);
 
                File.SetLastWriteTime(file, writeTime);
 
                // Write the same contents to the file, timestamps should match.
                var a2 = new WriteLinesToFile
                {
                    Overwrite = true,
                    BuildEngine = new MockEngine(_output),
                    TaskEnvironment = TaskEnvironmentHelper.CreateForTest(),
                    File = new TaskItem(file),
                    WriteOnlyWhenDifferent = true,
                    Lines = new ITaskItem[] { new TaskItem("File contents1") },
                    FailIfNotIncremental = true,
                };
                a2.Execute().ShouldBeTrue();
                File.GetLastWriteTime(file).ShouldBe(writeTime, tolerance: TimeSpan.FromSeconds(1));
 
                // Write different contents to the file, last write time should differ.
                var a3 = new WriteLinesToFile
                {
                    Overwrite = true,
                    BuildEngine = new MockEngine(_output),
                    TaskEnvironment = TaskEnvironmentHelper.CreateForTest(),
                    File = new TaskItem(file),
                    WriteOnlyWhenDifferent = true,
                    Lines = new ITaskItem[] { new TaskItem("File contents2") },
                    FailIfNotIncremental = true,
                };
                a3.Execute().ShouldBeFalse();
                File.GetLastWriteTime(file).ShouldBe(writeTime, tolerance: TimeSpan.FromSeconds(1));
            }
            finally
            {
                File.Delete(file);
            }
        }
 
        /// <summary>
        /// Question WriteLines to return true when Lines are empty.
        /// </summary>
        [Fact]
        public void QuestionWriteLinesWhenLinesAreEmpty()
        {
            // Test the combination of:
            // 1) File exists
            // 2) Overwrite
            // 3) WriteOnlyWhenDifferent
 
            var fileExists = FileUtilities.GetTemporaryFile();
            var fileNotExists = FileUtilities.GetTemporaryFileName();
            try
            {
                TestWriteLines(fileExists, fileNotExists, Overwrite: true, WriteOnlyWhenDifferent: true);
                TestWriteLines(fileExists, fileNotExists, Overwrite: false, WriteOnlyWhenDifferent: true);
                TestWriteLines(fileExists, fileNotExists, Overwrite: true, WriteOnlyWhenDifferent: false);
                TestWriteLines(fileExists, fileNotExists, Overwrite: false, WriteOnlyWhenDifferent: false);
            }
            finally
            {
                File.Delete(fileExists);
            }
 
            void TestWriteLines(string fileExists, string fileNotExists, bool Overwrite, bool WriteOnlyWhenDifferent)
            {
                var test1 = new WriteLinesToFile
                {
                    Overwrite = Overwrite,
                    BuildEngine = new MockEngine(_output),
                    TaskEnvironment = TaskEnvironmentHelper.CreateForTest(),
                    File = new TaskItem(fileExists),
                    WriteOnlyWhenDifferent = WriteOnlyWhenDifferent,
                    FailIfNotIncremental = true,
                    // Tests Lines = null.
                };
                test1.Execute().ShouldBeTrue();
 
                var test2 = new WriteLinesToFile
                {
                    Overwrite = Overwrite,
                    BuildEngine = new MockEngine(_output),
                    TaskEnvironment = TaskEnvironmentHelper.CreateForTest(),
                    File = new TaskItem(fileNotExists),
                    WriteOnlyWhenDifferent = WriteOnlyWhenDifferent,
                    FailIfNotIncremental = true,
                    Lines = Array.Empty<ITaskItem>(),  // Test empty.
                };
                test2.Execute().ShouldBeTrue();
            }
        }
 
        /// <summary>
        /// Should create directory structure when target <see cref="WriteLinesToFile.File"/> does not exist.
        /// </summary>
        [Fact]
        public void WriteLinesToFileDoesCreateDirectory()
        {
            using (var testEnv = TestEnvironment.Create())
            {
                var directory = testEnv.CreateFolder(folderPath: null, createFolder: false);
                var file = Path.Combine(directory.Path, $"{Guid.NewGuid().ToString("N")}.tmp");
 
                var WriteLinesToFile = new WriteLinesToFile
                {
                    BuildEngine = new MockEngine(_output),
                    TaskEnvironment = TaskEnvironmentHelper.CreateForTest(),
                    File = new TaskItem(file),
                    Lines = new ITaskItem[] { new TaskItem("WriteLinesToFileDoesCreateDirectory Test") }
                };
 
                // Verify that the diretory doesn't exist. Otherwise the test would pass - even it should not.
                Directory.Exists(directory.Path).ShouldBeFalse();
 
                WriteLinesToFile.Execute().ShouldBeTrue();
 
                Directory.Exists(directory.Path).ShouldBeTrue();
            }
        }
 
        [Theory]
        [InlineData(true)]
        [InlineData(false)]
        public void WritingNothingErasesExistingFile(bool useNullLines)
        {
            ITaskItem[] lines = useNullLines ? null : Array.Empty<ITaskItem>();
 
            using (var testEnv = TestEnvironment.Create())
            {
                var file = testEnv.CreateFile("FileToBeEmptied.txt", "Contents that should be erased");
 
                File.Exists(file.Path).ShouldBeTrue();
                File.ReadAllText(file.Path).ShouldNotBeEmpty();
 
                new WriteLinesToFile
                {
                    Overwrite = true,
                    BuildEngine = new MockEngine(_output),
                    TaskEnvironment = TaskEnvironmentHelper.CreateForTest(),
                    File = new TaskItem(file.Path),
                    Lines = lines
                }.Execute().ShouldBeTrue();
 
                File.Exists(file.Path).ShouldBeTrue();
                File.ReadAllText(file.Path).ShouldBeEmpty();
            }
        }
 
        [Theory]
        [InlineData(true)]
        [InlineData(false)]
        public void WritingNothingCreatesNewFile(bool useNullLines)
        {
            ITaskItem[] lines = useNullLines ? null : Array.Empty<ITaskItem>();
 
            using (var testEnv = TestEnvironment.Create())
            {
                var file = testEnv.GetTempFile();
 
                File.Exists(file.Path).ShouldBeFalse();
 
                new WriteLinesToFile
                {
                    Overwrite = true,
                    BuildEngine = new MockEngine(_output),
                    TaskEnvironment = TaskEnvironmentHelper.CreateForTest(),
                    File = new TaskItem(file.Path),
                    Lines = lines
                }.Execute().ShouldBeTrue();
 
                File.Exists(file.Path).ShouldBeTrue();
                File.ReadAllText(file.Path).ShouldBeEmpty();
            }
        }
 
        [Fact]
        public void TransactionalModeHandlesConcurrentWritesSuccessfully()
        {
            using (var testEnv = TestEnvironment.Create(_output))
            {
                var outputFile = Path.Combine(testEnv.DefaultTestDirectory.Path, "output.txt");
                var projectCount = 4;
 
                // Create parent project file to run child projects in parallel
                var parallelProjectContent = @$"
            <Project xmlns=""http://schemas.microsoft.com/developer/msbuild/2003"">
                <ItemGroup>
            {string.Join("\n", Enumerable.Range(1, projectCount).Select(i => $@"<Project Include=""TestProject{i}.csproj"" />"))}
                </ItemGroup>
                <Target Name=""Build"">
                <MSBuild Projects=""@(Project)"" Targets=""WriteToFile"" BuildInParallel=""true""/>
                </Target>
            </Project>";
                var parallelProjectFile = testEnv.CreateFile("ParallelBuildProject.csproj", parallelProjectContent).Path;
 
                // Create child project instances - using overwrite mode to avoid race conditions in append mode
                for (int i = 0; i < projectCount; i++)
                {
                    var projectContent = @$"
                <Project xmlns=""http://schemas.microsoft.com/developer/msbuild/2003"">
                    <ItemGroup>
                    <LinesToWrite Include=""Line from Test{i + 1}"" />
                    </ItemGroup>
                    <Target Name=""WriteToFile"">
                    <WriteLinesToFile File=""{outputFile}"" Lines=""@(LinesToWrite)"" Overwrite=""true""/>
                    </Target>
                </Project>";
                    testEnv.CreateFile($"TestProject{i + 1}.csproj", projectContent);
                }
 
                // Build using ProjectCollection as recommended by Change Waves documentation
                // This ensures change wave state is properly respected
                using (var collection = new ProjectCollection(
                    globalProperties: null,
                    loggers: null,
                    remoteLoggers: null,
                    toolsetDefinitionLocations: ToolsetDefinitionLocations.Default,
                    maxNodeCount: Environment.ProcessorCount,
                    onlyLogCriticalEvents: false))
                {
                    var project = collection.LoadProject(parallelProjectFile);
                    var buildResult = project.Build("Build");
                }
 
                // Verify output file exists and contains content
                // Note: Without mutex, there may be race conditions, but atomic replace prevents corruption
                File.Exists(outputFile).ShouldBeTrue();
                var content = File.ReadAllText(outputFile);
                content.ShouldNotBeEmpty();
                
                // Verify at least some lines were written (exact count may vary due to race conditions)
                var lines = content.Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries);
                lines.Length.ShouldBeGreaterThan(0, "At least some lines should be written");
            }
        }
 
        [Fact]
        public void TransactionalModePreservesAllData()
        {
            using (var testEnv = TestEnvironment.Create(_output))
            {
                var outputFile = Path.Combine(testEnv.DefaultTestDirectory.Path, "output.txt");
                var projectCount = 4;
 
                var parallelProjectContent = @$"
            <Project xmlns=""http://schemas.microsoft.com/developer/msbuild/2003"">
                <ItemGroup>
            {string.Join("\n", Enumerable.Range(1, projectCount).Select(i => $@"<Project Include=""TestProject{i}.csproj"" />"))}
                </ItemGroup>
                <Target Name=""Build"">
                <MSBuild Projects=""@(Project)"" Targets=""WriteToFile"" BuildInParallel=""true""/>
                </Target>
            </Project>";
                var parallelProjectFile = testEnv.CreateFile("ParallelBuildProject.csproj", parallelProjectContent).Path;
 
                // Use Overwrite mode instead of Append mode to avoid race conditions when reading existing content
                // Transactional mode ensures atomic replace, preventing file corruption
                for (int i = 0; i < projectCount; i++)
                {
                    var projectContent = @$"
                <Project xmlns=""http://schemas.microsoft.com/developer/msbuild/2003"">
                    <ItemGroup>
                    <LinesToWrite Include=""Line from Project {i + 1}"" />
                    </ItemGroup>
                    <Target Name=""WriteToFile"">
                    <WriteLinesToFile File=""{outputFile}"" Lines=""@(LinesToWrite)"" Overwrite=""true""/>
                    </Target>
                </Project>";
                    testEnv.CreateFile($"TestProject{i + 1}.csproj", projectContent);
                }
 
                // Build using ProjectCollection as recommended by Change Waves documentation
                var logger = new MockLogger(_output);
                using (var collection = new ProjectCollection(
                    globalProperties: null,
                    loggers: [logger],
                    remoteLoggers: null,
                    toolsetDefinitionLocations: ToolsetDefinitionLocations.Default,
                    maxNodeCount: Environment.ProcessorCount,
                    onlyLogCriticalEvents: false))
                {
                    var project = collection.LoadProject(parallelProjectFile);
                    var buildResult = project.Build("Build");
 
                    // With transactional mode and Overwrite=true, build should succeed
                    // Atomic replace prevents file corruption even with concurrent writes
                    buildResult.ShouldBeTrue();
                }
 
                // Verify file exists and has content from one of the projects
                File.Exists(outputFile).ShouldBeTrue();
                var content = File.ReadAllText(outputFile);
                content.ShouldNotBeEmpty();
                var lines = content.Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries);
 
                // With Overwrite=true, only the last write will survive (no data preservation in overwrite mode)
                // But transactional mode ensures the write succeeds without corruption
                lines.Length.ShouldBeGreaterThan(0, "At least one line should be written");
                
                // Verify that at least one project's output appears (the last one to write)
                bool foundProject = false;
                for (int i = 1; i <= projectCount; i++)
                {
                    if (lines.Any(line => line.Contains($"Line from Project {i}")))
                    {
                        foundProject = true;
                        break;
                    }
                }
                foundProject.ShouldBeTrue("At least one project's output should be in the file");
            }
        }
 
        [Fact]
        public void NonTransactionalModeCausesDataLoss()
        {
            using (var testEnv = TestEnvironment.Create(_output))
            {
                // Disable transactional mode via changewave to test non-transactional behavior
                ChangeWaves.ResetStateForTests();
                testEnv.SetEnvironmentVariable("MSBUILDDISABLEFEATURESFROMVERSION", ChangeWaves.Wave18_3.ToString());
                BuildEnvironmentHelper.ResetInstance_ForUnitTestsOnly();
                var outputFile = testEnv.CreateFile("output.txt").Path;
                var projectCount = 20; 
 
                var parallelProjectContent = @$"
            <Project xmlns=""http://schemas.microsoft.com/developer/msbuild/2003"">
                <ItemGroup>
            {string.Join("\n", Enumerable.Range(1, projectCount).Select(i => $@"<Project Include=""TestProject{i}.csproj"" />"))}
                </ItemGroup>
                <Target Name=""Build"">
                <MSBuild Projects=""@(Project)"" Targets=""WriteToFile"" BuildInParallel=""true""/>
                </Target>
            </Project>";
                var parallelProjectFile = testEnv.CreateFile("ParallelBuildProject.csproj", parallelProjectContent).Path;
 
                for (int i = 0; i < projectCount; i++)
                {
                    var projectContent = @$"
                <Project xmlns=""http://schemas.microsoft.com/developer/msbuild/2003"">
                    <ItemGroup>
                    <LinesToWrite Include=""Line from Project {i + 1}"" />
                    </ItemGroup>
                    <Target Name=""WriteToFile"">
                    <!-- NO Transactional mode, Overwrite=true -->
                    <WriteLinesToFile File=""{outputFile}"" Lines=""@(LinesToWrite)"" Overwrite=""true""/>
                    <WriteLinesToFile File=""{outputFile}"" Lines=""@(LinesToWrite)"" Overwrite=""true""/>
                    <WriteLinesToFile File=""{outputFile}"" Lines=""@(LinesToWrite)"" Overwrite=""true""/>
                    <WriteLinesToFile File=""{outputFile}"" Lines=""@(LinesToWrite)"" Overwrite=""true""/>
                    <WriteLinesToFile File=""{outputFile}"" Lines=""@(LinesToWrite)"" Overwrite=""true""/>
                    </Target>
                </Project>";
                    testEnv.CreateFile($"TestProject{i + 1}.csproj", projectContent);
                }
 
                // Build using ProjectCollection as recommended by Change Waves documentation
                using (var collection = new ProjectCollection(
                    globalProperties: null,
                    loggers: null,
                    remoteLoggers: null,
                    toolsetDefinitionLocations: ToolsetDefinitionLocations.Default,
                    maxNodeCount: Environment.ProcessorCount,
                    onlyLogCriticalEvents: false))
                {
                    var project = collection.LoadProject(parallelProjectFile);
                    var buildSucceeded = project.Build("Build");
 
                    // With non-transactional mode and concurrent writes, build may fail due to file locking
                    // or succeed with data loss. Either outcome demonstrates the problem with non-transactional mode.
                    // If build succeeded, verify data loss occurred
                    if (buildSucceeded)
                {
 
                var content = File.ReadAllText(outputFile);
                var lines = content.Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries);
 
                var expectedWithoutRace = projectCount * 5;
 
                // Without transactional mode and with Overwrite=true, concurrent writes will overwrite each other
                // We expect significant data loss - only the last write(s) will survive
                lines.Length.ShouldBeLessThan(expectedWithoutRace,
                    $"Without transactional mode, data loss should occur. " +
                    $"Expected significant data loss from {expectedWithoutRace} lines, but got {lines.Length}");
 
                    // With Overwrite=true and parallel builds without transactional mode, 
                    // only the last few writes should survive (typically 1-5 lines)
                    lines.Length.ShouldBeLessThanOrEqualTo(5,
                        "With Overwrite=true and parallel builds without transactional mode, " +
                        "only last project's writes should survive due to race conditions");
                    }
                    // If build failed, that's also acceptable - it demonstrates file locking issues with non-transactional mode
                }
            }
        }
    }
}