diff --git a/src/NuGet.Core/Microsoft.Build.NuGetSdkResolver/GlobalJsonReader.cs b/src/NuGet.Core/Microsoft.Build.NuGetSdkResolver/GlobalJsonReader.cs index 2305b8e4471..035802389da 100644 --- a/src/NuGet.Core/Microsoft.Build.NuGetSdkResolver/GlobalJsonReader.cs +++ b/src/NuGet.Core/Microsoft.Build.NuGetSdkResolver/GlobalJsonReader.cs @@ -1,11 +1,14 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +using Microsoft.Build.Shared; +using Newtonsoft.Json; using System; using System.Collections.Generic; using System.Globalization; using System.IO; -using System.Xml; +using System.Linq; +using System.Runtime.CompilerServices; using Microsoft.Build.Framework; namespace Microsoft.Build.NuGetSdkResolver @@ -25,26 +28,27 @@ internal static class GlobalJsonReader /// A of MSBuild SDK versions from a global.json if found, otherwise null. public static Dictionary GetMSBuildSdkVersions(SdkResolverContext context) { - var projectFile = new FileInfo(context.ProjectFilePath); + var projectDirectory = Directory.GetParent(context.ProjectFilePath); - if (!TryGetPathOfFileAbove(GlobalJsonFileName, projectFile.Directory, out var globalJsonPath)) + if (projectDirectory == null + || !projectDirectory.Exists + || !TryGetPathOfFileAbove(GlobalJsonFileName, projectDirectory.FullName, out var globalJsonPath)) { return null; } - // Read the contents of global.json - var globalJsonContents = File.ReadAllText(globalJsonPath); + var contents = File.ReadAllText(globalJsonPath); // Look ahead in the contents to see if there is an msbuild-sdks section. Deserializing the file requires us to load - // additional assemblies which can be a waste since global.json is usually ~100 bytes of text. - if (globalJsonContents.IndexOf(MSBuildSdksPropertyName, StringComparison.Ordinal) == -1) + // Newtonsoft.Json which is 500 KB while a global.json is usually ~100 bytes of text. + if (contents.IndexOf(MSBuildSdksPropertyName, StringComparison.Ordinal) == -1) { return null; } try { - return ParseMSBuildSdksFromGlobalJson(globalJsonPath); + return Deserialize(contents); } catch (Exception e) { @@ -56,86 +60,85 @@ internal static class GlobalJsonReader } /// - /// Searches for a file based on the specified starting directory. + /// Deserializes a global.json and returns the MSBuild SDK versions /// - /// The name of the file to search for. - /// An optional representing the directory to start the search in. - /// Receives the of the file if found, otherwise null. - /// true if a file was found in the directory or any parent directory, otherwise false. - public static bool TryGetPathOfFileAbove(string fileName, DirectoryInfo startingDirectory, out string result) + [MethodImpl(MethodImplOptions.NoInlining)] + private static Dictionary Deserialize(string value) { - result = null; - - if (startingDirectory == null || !startingDirectory.Exists) - { - return false; - } - - var lookInDirectory = startingDirectory; + return JsonConvert.DeserializeObject(value).MSBuildSdks; + } - do - { - var possibleFile = new FileInfo(Path.Combine(lookInDirectory.FullName, fileName)); + private sealed class GlobalJsonFile + { + [JsonProperty(MSBuildSdksPropertyName)] + public Dictionary MSBuildSdks { get; set; } + } - if (possibleFile.Exists) - { - result = possibleFile.FullName; + /// + /// Searches for a file based on the specified starting directory. + /// + /// The file to search for. + /// An optional directory to start the search in. The default location is the directory + /// of the file containing the property function. + /// The full path of the file if it is found, otherwise an empty string. + private static string GetPathOfFileAbove(string file, string startingDirectory) + { + // Search for a directory that contains that file + var directoryName = GetDirectoryNameOfFileAbove(startingDirectory, file); - return true; - } + return string.IsNullOrEmpty(directoryName) ? string.Empty : NormalizePath(Path.Combine(directoryName, file)); + } - lookInDirectory = lookInDirectory.Parent; - } - while (lookInDirectory != null); + private static bool TryGetPathOfFileAbove(string file, string startingDirectory, out string fullPath) + { + fullPath = GetPathOfFileAbove(file, startingDirectory); - return false; + return fullPath != string.Empty; } /// - /// Parses a global.json and returns the MSBuild SDK versions. + /// Locate a file in either the directory specified or a location in the + /// directory structure above that directory. /// - /// - /// NoInlining is enabled ensure that System.Runtime.Serialization.Json.dll isn't loaded unless the method is called. - /// - [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] - private static Dictionary ParseMSBuildSdksFromGlobalJson(string path) + private static string GetDirectoryNameOfFileAbove(string startingDirectory, string fileName) { - Dictionary sdks = null; + // Canonicalize our starting location + var lookInDirectory = Path.GetFullPath(startingDirectory); - using (var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read)) - using (var reader = System.Runtime.Serialization.Json.JsonReaderWriterFactory.CreateJsonReader(stream, XmlDictionaryReaderQuotas.Max)) + do { - while (reader.Read()) + // Construct the path that we will use to test against + var possibleFileDirectory = Path.Combine(lookInDirectory, fileName); + + // If we successfully locate the file in the directory that we're + // looking in, simply return that location. Otherwise we'll + // keep moving up the tree. + if (File.Exists(possibleFileDirectory)) + { + // We've found the file, return the directory we found it in + return lookInDirectory; + } + else { - if (reader.LocalName.Equals(MSBuildSdksPropertyName) && reader.Depth == 1) - { - while (reader.Read()) - { - if (reader.NodeType == XmlNodeType.Element) - { - string name = reader.LocalName.Trim(); - - if (!string.IsNullOrWhiteSpace(name) && reader.Read() && reader.NodeType == XmlNodeType.Text) - { - if (sdks == null) - { - sdks = new Dictionary(StringComparer.OrdinalIgnoreCase); - } - - var value = reader.Value.Trim(); - - if (!string.IsNullOrWhiteSpace(value)) - { - sdks[name] = value; - } - } - } - } - } + // GetDirectoryName will return null when we reach the root + // terminating our search + lookInDirectory = Path.GetDirectoryName(lookInDirectory); } } + while (lookInDirectory != null); + + // When we didn't find the location, then return an empty string + return string.Empty; + } - return sdks; + private static string NormalizePath(string path) + { + return FixFilePath(Path.GetFullPath(path)); + + } + private static string FixFilePath(string path) + { + return string.IsNullOrEmpty(path) || Path.DirectorySeparatorChar == '\\' ? path : path.Replace('\\', '/'); } } } diff --git a/test/NuGet.Core.Tests/Microsoft.Build.NuGetSdkResolver.Tests/GlobalJsonReader_Tests.cs b/test/NuGet.Core.Tests/Microsoft.Build.NuGetSdkResolver.Tests/GlobalJsonReader_Tests.cs index 787acad6c1c..259f1d2f5c1 100644 --- a/test/NuGet.Core.Tests/Microsoft.Build.NuGetSdkResolver.Tests/GlobalJsonReader_Tests.cs +++ b/test/NuGet.Core.Tests/Microsoft.Build.NuGetSdkResolver.Tests/GlobalJsonReader_Tests.cs @@ -6,47 +6,89 @@ using System.IO; using System.Linq; using FluentAssertions; -using NuGet.Test.Utility; using Xunit; namespace Microsoft.Build.NuGetSdkResolver.Test { public class GlobalJsonReaderTests { + public static string WriteGlobalJson(string directory, Dictionary sdkVersions, string additionalcontent = "") + { + var path = Path.Combine(directory, GlobalJsonReader.GlobalJsonFileName); + + using (var writer = File.CreateText(path)) + { + writer.WriteLine("{"); + if (sdkVersions != null) + { + writer.WriteLine(" \"msbuild-sdks\": {"); + writer.WriteLine(string.Join($",{Environment.NewLine} ", sdkVersions.Select(i => $"\"{i.Key}\": \"{i.Value}\""))); + writer.WriteLine(" }"); + } + + if (!string.IsNullOrWhiteSpace(additionalcontent)) + { + writer.Write(additionalcontent); + } + + writer.WriteLine("}"); + } + + return path; + } + [Fact] public void EmptyGlobalJson() { - using (var testDirectory = TestDirectory.Create()) + using (var testEnvironment = TestEnvironment.Create()) { - File.WriteAllText(Path.Combine(testDirectory.Path, GlobalJsonReader.GlobalJsonFileName), @" { } "); + TransientTestFolder folder = testEnvironment.CreateFolder(); - var context = new MockSdkResolverContext(Path.Combine(testDirectory.Path, "foo.proj")); + try + { + File.WriteAllText(Path.Combine(folder.FolderPath, GlobalJsonReader.GlobalJsonFileName), @" { } "); + + var context = new MockSdkResolverContext(Path.Combine(folder.FolderPath, "foo.proj")); - GlobalJsonReader.GetMSBuildSdkVersions(context).Should().BeNull(); + GlobalJsonReader.GetMSBuildSdkVersions(context).Should().BeNull(); + } + finally + { + folder.Revert(); + } } } [Fact] - public void EmptyVersionsAreIgnored() + public void GlobalJsonWithComments() { - using (var testDirectory = TestDirectory.Create()) + using (var testEnvironment = TestEnvironment.Create()) { - var context = new MockSdkResolverContext(Path.Combine(testDirectory.Path, "foo.proj")); + TransientTestFolder folder = testEnvironment.CreateFolder(); - WriteGlobalJson( - testDirectory, - new Dictionary + try + { + File.WriteAllText( + Path.Combine(folder.FolderPath, GlobalJsonReader.GlobalJsonFileName), + @"{ + // This is a comment + ""msbuild-sdks"": { + /* This is another comment */ + ""foo"": ""1.0.0"" + } +}"); + + var context = new MockSdkResolverContext(Path.Combine(folder.FolderPath, "foo.proj")); + + GlobalJsonReader.GetMSBuildSdkVersions(context).ShouldAllBeEquivalentTo(new Dictionary { - ["foo"] = "1.0.0", - ["bar"] = "", - ["baz"] = " ", - ["bax"] = null + ["foo"] = "1.0.0" }); - - GlobalJsonReader.GetMSBuildSdkVersions(context).Should().Equal(new Dictionary + } + finally { - ["foo"] = "1.0.0" - }); + folder.Revert(); + } } } @@ -59,17 +101,27 @@ public void InvalidJsonLogsMessage() {"bar", "2.0.0"} }; - using (var testDirectory = TestDirectory.Create()) + using (var testEnvironment = TestEnvironment.Create()) { - var globalJsonPath = WriteGlobalJson(testDirectory, expectedVersions, additionalContent: ", abc"); + var testFolder = testEnvironment.CreateFolder(); + + try + { + var projectFile = testEnvironment.CreateFile(testFolder, ".proj"); + var globalJsonPath = WriteGlobalJson(testFolder.FolderPath, expectedVersions, additionalcontent: ", abc"); - var context = new MockSdkResolverContext(Path.Combine(testDirectory.Path, "foo.proj")); + var context = new MockSdkResolverContext(projectFile.Path); - GlobalJsonReader.GetMSBuildSdkVersions(context).Should().BeNull(); + GlobalJsonReader.GetMSBuildSdkVersions(context).Should().BeNull(); - context.MockSdkLogger.LoggedMessages.Count.Should().Be(1); - context.MockSdkLogger.LoggedMessages.First().Key.Should().Be( - $"Failed to parse \"{globalJsonPath}\". Encountered unexpected character 'a'."); + context.MockSdkLogger.LoggedMessages.Count.Should().Be(1); + context.MockSdkLogger.LoggedMessages.First().Key.Should().Be( + $"Failed to parse \"{globalJsonPath}\". Invalid JavaScript property identifier character: }}. Path \'msbuild-sdks\', line 6, position 5."); + } + finally + { + testFolder.Revert(); + } } } @@ -82,70 +134,25 @@ public void SdkVersionsAreSuccessfullyLoaded() {"bar", "2.0.0"} }; - using (var testDirectory = TestDirectory.Create()) + using (var testEnvironment = TestEnvironment.Create()) { - WriteGlobalJson(testDirectory, expectedVersions); - - var context = new MockSdkResolverContext(Path.Combine(testDirectory.Path, "foo.proj")); - - GlobalJsonReader.GetMSBuildSdkVersions(context).Should().Equal(expectedVersions); - } - } - - [Theory] - [InlineData("one")] - [InlineData("one", "two")] - [InlineData("one", "two", "three")] - public void TryGetPathOfFileAboveRecursive(params string[] directories) - { - const string filename = "test.txt"; + var testFolder = testEnvironment.CreateFolder(); - using (var testDirectory = TestDirectory.Create()) - { - var paths = new List + try { - testDirectory.Path - }; - - paths.AddRange(directories); - - var directory = new DirectoryInfo(Path.Combine(paths.ToArray())); + var projectFile = testEnvironment.CreateFile(testFolder, ".proj"); - directory.Create(); + WriteGlobalJson(testFolder.FolderPath, expectedVersions); - var expected = Path.Combine(testDirectory.Path, filename); + var context = new MockSdkResolverContext(projectFile.Path); - File.WriteAllText(expected, string.Empty); - - GlobalJsonReader.TryGetPathOfFileAbove(filename, directory, out string result).Should().BeTrue(); - - result.Should().Be(expected); - } - } - - internal static string WriteGlobalJson(TestDirectory testDirectory, Dictionary sdkVersions, string additionalContent = "") - { - var path = Path.Combine(testDirectory, GlobalJsonReader.GlobalJsonFileName); - - using (var writer = File.CreateText(path)) - { - writer.WriteLine("{"); - if (sdkVersions != null) - { - writer.WriteLine(" \"msbuild-sdks\": {"); - writer.WriteLine(string.Join($",{Environment.NewLine} ", sdkVersions.Select(i => $"\"{i.Key}\": {(i.Value == null ? "null" : $"\"{i.Value}\"")}"))); - writer.WriteLine(" }"); + GlobalJsonReader.GetMSBuildSdkVersions(context).Should().Equal(expectedVersions); } - - if (!string.IsNullOrWhiteSpace(additionalContent)) + finally { - writer.Write(additionalContent); + testFolder.Revert(); } - - writer.WriteLine("}"); } - - return path; } } } diff --git a/test/NuGet.Core.Tests/Microsoft.Build.NuGetSdkResolver.Tests/Microsoft.Build.NuGetSdkResolver.Test.csproj b/test/NuGet.Core.Tests/Microsoft.Build.NuGetSdkResolver.Tests/Microsoft.Build.NuGetSdkResolver.Test.csproj index 4d3bef1f65f..534ff100826 100644 --- a/test/NuGet.Core.Tests/Microsoft.Build.NuGetSdkResolver.Tests/Microsoft.Build.NuGetSdkResolver.Test.csproj +++ b/test/NuGet.Core.Tests/Microsoft.Build.NuGetSdkResolver.Tests/Microsoft.Build.NuGetSdkResolver.Test.csproj @@ -1,4 +1,4 @@ - + @@ -11,7 +11,6 @@ - diff --git a/test/NuGet.Core.Tests/Microsoft.Build.NuGetSdkResolver.Tests/NuGetSdkResolver_Tests.cs b/test/NuGet.Core.Tests/Microsoft.Build.NuGetSdkResolver.Tests/NuGetSdkResolver_Tests.cs index 55d7577099b..fcc5cea6920 100644 --- a/test/NuGet.Core.Tests/Microsoft.Build.NuGetSdkResolver.Tests/NuGetSdkResolver_Tests.cs +++ b/test/NuGet.Core.Tests/Microsoft.Build.NuGetSdkResolver.Tests/NuGetSdkResolver_Tests.cs @@ -1,12 +1,14 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +using System; +using NuGet.Versioning; using System.Collections.Generic; -using System.IO; +using System.Linq; +using System.Reflection; using FluentAssertions; -using NuGet.Test.Utility; -using NuGet.Versioning; using Xunit; + using SdkResolverContextBase = Microsoft.Build.Framework.SdkResolverContext; namespace Microsoft.Build.NuGetSdkResolver.Test @@ -22,11 +24,14 @@ public void TryGetNuGetVersionForSdkGetsVersionFromGlobalJson() {"bar", "2.0.0"} }; - using (var testDirectory = TestDirectory.Create()) + using (var testEnvironment = TestEnvironment.Create()) { - GlobalJsonReaderTests.WriteGlobalJson(testDirectory, expectedVersions); + var testFolder = testEnvironment.CreateFolder(); + var projectFile = testEnvironment.CreateFile(testFolder, ".proj"); + + GlobalJsonReaderTests.WriteGlobalJson(testFolder.FolderPath, expectedVersions); - var context = new MockSdkResolverContext(Path.Combine(testDirectory.Path, "foo.proj")); + var context = new MockSdkResolverContext(projectFile.Path); VerifyTryGetNuGetVersionForSdk( version: null, diff --git a/test/NuGet.Core.Tests/Microsoft.Build.NuGetSdkResolver.Tests/TestEnvironment.cs b/test/NuGet.Core.Tests/Microsoft.Build.NuGetSdkResolver.Tests/TestEnvironment.cs new file mode 100644 index 00000000000..24d3a68f5e5 --- /dev/null +++ b/test/NuGet.Core.Tests/Microsoft.Build.NuGetSdkResolver.Tests/TestEnvironment.cs @@ -0,0 +1,1301 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +// Note: This was taken from: +// https://github.com/Microsoft/msbuild/blob/6b395a35e3ef2353a5473a718c2262d1f095fd2c/src/Shared/UnitTests/TestEnvironment.cs +// It is only used for test purposes and no need to keep in sync. All of the test dependencies are included in this file. + +using System; +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Security; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading; +using FluentAssertions; +using Microsoft.Build.Framework; +using Xunit; +using Xunit.Abstractions; + +using TempPaths = System.Collections.Generic.Dictionary; + +namespace Microsoft.Build.NuGetSdkResolver.Test +{ + public class TestEnvironment : IDisposable + { + /// + /// List of test invariants to assert value does not change. + /// + private readonly List _invariants = new List(); + + /// + /// List of test variants which need to be reverted when the test completes. + /// + private readonly List _variants = new List(); + + private readonly ITestOutputHelper _output; + + private readonly Lazy _defaultTestDirectory; + + private bool _disposed; + + public TransientTestFolder DefaultTestDirectory => _defaultTestDirectory.Value; + + public static TestEnvironment Create(ITestOutputHelper output = null, bool ignoreBuildErrorFiles = false, bool setDefaultInvariants = false) + { + var env = new TestEnvironment(output ?? new DefaultOutput(), setDefaultInvariants); + + // In most cases, if MSBuild wrote an MSBuild_*.txt to the temp path something went wrong. + if (!ignoreBuildErrorFiles) + { + env.WithInvariant(new BuildFailureLogInvariant()); + } + + env.SetEnvironmentVariable("MSBUILDRELOADTRAITSONEACHACCESS", "1"); + + return env; + } + + private TestEnvironment(ITestOutputHelper output, bool setDefaultInvariants = true) + { + _output = output; + _defaultTestDirectory = new Lazy(() => CreateFolder()); + if (setDefaultInvariants) SetDefaultInvariant(); + } + + public void Dispose() + { + Cleanup(); + GC.SuppressFinalize(this); + } + + ~TestEnvironment() + { + Cleanup(); + } + + /// + /// Revert / cleanup variants and then assert invariants. + /// + private void Cleanup() + { + if (!_disposed) + { + _disposed = true; + + // Reset test variants + foreach (var variant in _variants) + variant.Revert(); + + // Assert invariants + foreach (var item in _invariants) + item.AssertInvariant(_output); + + if (_defaultTestDirectory.IsValueCreated) + { + _defaultTestDirectory.Value.Revert(); + } + } + } + + /// + /// Evaluate the test with the given invariant. + /// + /// Test invariant to assert unchanged on completion. + public T WithInvariant(T invariant) where T : TestInvariant + { + _invariants.Add(invariant); + return invariant; + } + + /// + /// Evaluate the test with the given transient test state. + /// + /// Test state to revert on completion. + public T WithTransientTestState(T transientState) where T : TransientTestState + { + _variants.Add(transientState); + return transientState; + } + + /// + /// Clears all test invariants. This should only be used if there is a known + /// issue with a test! + /// + public void ClearTestInvariants() + { + _invariants.Clear(); + } + + #region Common test variants + + private void SetDefaultInvariant() + { + // Temp folder should not change before and after a test + WithInvariant(new StringInvariant("Path.GetTempPath()", Path.GetTempPath)); + + // Temp folder should not change before and after a test + WithInvariant(new StringInvariant("Directory.GetCurrentDirectory", Directory.GetCurrentDirectory)); + + WithEnvironmentInvariant(); + } + + /// + /// Creates a test invariant that asserts an environment variable does not change during the test. + /// + /// Name of the environment variable. + public TestInvariant WithEnvironmentVariableInvariant(string environmentVariableName) + { + return WithInvariant(new StringInvariant(environmentVariableName, + () => Environment.GetEnvironmentVariable(environmentVariableName))); + } + /// + /// Creates a test invariant which asserts that the environment variables do not change + /// + public TestInvariant WithEnvironmentInvariant() + { + return WithInvariant(new EnvironmentInvariant()); + } + + /// + /// Creates a string invariant that will assert the value is the same before and after the test. + /// + /// Name of the item to keep track of. + /// Delegate to get the value for the invariant. + public TestInvariant WithStringInvariant(string name, Func value) + { + return WithInvariant(new StringInvariant(name, value)); + } + + /// + /// Creates a new temp path + /// + public TransientTempPath CreateNewTempPath() + { + var folder = CreateFolder(); + return SetTempPath(folder.FolderPath, true); + } + + /// + /// Creates a new temp path + /// Sets all OS temp environment variables to the new path + /// + /// Cleanup: + /// - restores OS temp environment variables + /// + public TransientTempPath SetTempPath(string tempPath, bool deleteTempDirectory = false) + { + var transientTempPath = new TransientTempPath(tempPath, deleteTempDirectory); + _variants.Add(transientTempPath); + + return transientTempPath; + } + + /// + /// Creates a test variant that corresponds to a temporary file which will be deleted when the test completes. + /// + /// Extensions of the file (defaults to '.tmp') + public TransientTestFile CreateFile(string extension = ".tmp") + { + return WithTransientTestState(new TransientTestFile(extension, createFile:true, expectedAsOutput:false)); + } + + public TransientTestFile CreateFile(string fileName, string contents = "") + { + return CreateFile(DefaultTestDirectory, fileName, contents); + } + + public TransientTestFile CreateFile(TransientTestFolder transientTestFolder, string fileName, string contents = "") + { + var file = WithTransientTestState(new TransientTestFile(transientTestFolder.FolderPath, Path.GetFileNameWithoutExtension(fileName), Path.GetExtension(fileName))); + File.WriteAllText(file.Path, contents); + + return file; + } + + /// + /// Creates a test variant that corresponds to a temporary file under a specific temporary folder. File will + /// be cleaned up when the test completes. + /// + /// + /// Extension of the file (defaults to '.tmp') + public TransientTestFile CreateFile(TransientTestFolder transientTestFolder, string extension = ".tmp") + { + return WithTransientTestState(new TransientTestFile(transientTestFolder.FolderPath, extension, + createFile: true, expectedAsOutput: false)); + } + + + /// + /// Gets a transient test file associated with a unique file name but does not create the file. + /// + /// Extension of the file (defaults to '.tmp') + /// + public TransientTestFile GetTempFile(string extension = ".tmp") + { + return WithTransientTestState(new TransientTestFile(extension, createFile: false, expectedAsOutput: false)); + } + + /// + /// Gets a transient test file under a specified folder associated with a unique file name but does not create the file. + /// + /// Temp folder + /// Extension of the file (defaults to '.tmp') + /// + public TransientTestFile GetTempFile(TransientTestFolder transientTestFolder, string extension = ".tmp") + { + return WithTransientTestState(new TransientTestFile(transientTestFolder.FolderPath, extension, + createFile: false, expectedAsOutput: false)); + } + + /// + /// Create a temp file name that is expected to exist when the test completes. + /// + /// Extension of the file (defaults to '.tmp') + /// + public TransientTestFile ExpectFile(string extension = ".tmp") + { + return WithTransientTestState(new TransientTestFile(extension, createFile: false, expectedAsOutput: true)); + } + + /// + /// Create a temp file name under a specific temporary folder. The file is expected to exist when the test completes. + /// + /// Temp folder + /// Extension of the file (defaults to '.tmp') + /// + public TransientTestFile ExpectFile(TransientTestFolder transientTestFolder, string extension = ".tmp") + { + return WithTransientTestState(new TransientTestFile(transientTestFolder.FolderPath, extension, createFile: false, expectedAsOutput: true)); + } + + /// + /// Creates a test variant used to add a unique temporary folder during a test. Will be deleted when the test + /// completes. + /// + public TransientTestFolder CreateFolder(string folderPath = null, bool createFolder = true) + { + var folder = WithTransientTestState(new TransientTestFolder(folderPath, createFolder)); + + Assert.True(!(createFolder ^ Directory.Exists(folder.FolderPath))); + + return folder; + } + + /// + /// Creates a test variant used to add a unique temporary folder during a test. Will be deleted when the test + /// completes. + /// + public TransientTestFolder CreateFolder(bool createFolder) + { + return CreateFolder(null, createFolder); + } + + /// + /// Create an test variant used to change the value of an environment variable during a test. Original value + /// will be restored when complete. + /// + public TransientTestState SetEnvironmentVariable(string environmentVariableName, string newValue) + { + return WithTransientTestState(new TransientTestEnvironmentVariable(environmentVariableName, newValue)); + } + + public TransientTestState SetCurrentDirectory(string newWorkingDirectory) + { + return WithTransientTestState(new TransientWorkingDirectory(newWorkingDirectory)); + } + + #endregion + + private class DefaultOutput : ITestOutputHelper + { + public void WriteLine(string message) + { + Console.WriteLine(message); + } + + public void WriteLine(string format, params object[] args) + { + Console.WriteLine(format, args); + } + } + + /// + /// Creates a test variant representing a test project with files relative to the project root. All files + /// and the root will be cleaned up when the test completes. + /// + /// Contents of the project file to be created. + /// Files to be created. + /// Path for the specified files to be created in relative to + /// the root of the project directory. + public TransientTestProjectWithFiles CreateTestProjectWithFiles(string projectContents, string[] files = null, string relativePathFromRootToProject = ".") + { + return WithTransientTestState( + new TransientTestProjectWithFiles(projectContents, files, relativePathFromRootToProject)); + } + } + + /// + /// Things that are expected not to change and should be asserted before and after running. + /// + public abstract class TestInvariant + { + public abstract void AssertInvariant(ITestOutputHelper output); + } + + /// + /// Things that are expected to change and should be reverted after running. + /// + public abstract class TransientTestState + { + public abstract void Revert(); + } + + public class StringInvariant : TestInvariant + { + private readonly Func _accessorFunc; + private readonly string _name; + private readonly string _originalValue; + + public StringInvariant(string name, Func accessorFunc) + { + _name = name; + _accessorFunc = accessorFunc; + _originalValue = accessorFunc(); + } + + public override void AssertInvariant(ITestOutputHelper output) + { + var currentValue = _accessorFunc(); + + // Something like the following might be preferrable, but the assertion method truncates the values leaving us without + // useful information. So use Assert.True instead + // Assert.Equal($"{_name}: {_originalValue}", $"{_name}: {_accessorFunc()}"); + + Assert.True(currentValue == _originalValue, $"Expected {_name} to be '{_originalValue}', but it was '{currentValue}'"); + } + } + + public class EnvironmentInvariant : TestInvariant + { + private readonly IDictionary _initialEnvironment; + + public EnvironmentInvariant() + { + _initialEnvironment = Environment.GetEnvironmentVariables(); + } + + public override void AssertInvariant(ITestOutputHelper output) + { + var environment = Environment.GetEnvironmentVariables(); + + AssertDictionaryInclusion(_initialEnvironment, environment, "added"); + AssertDictionaryInclusion(environment, _initialEnvironment, "removed"); + + // a includes b + void AssertDictionaryInclusion(IDictionary a, IDictionary b, string operation) + { + foreach (var key in b.Keys) + { + a.Contains(key).Should().Be(true, $"environment variable {operation}: {key}"); + a[key].Should().Be(b[key]); + } + } + } + } + + public class BuildFailureLogInvariant : TestInvariant + { + private readonly string[] _originalFiles; + + public BuildFailureLogInvariant() + { + _originalFiles = Directory.GetFiles(Path.GetTempPath(), "MSBuild_*.txt"); + } + + public override void AssertInvariant(ITestOutputHelper output) + { + var newFiles = Directory.GetFiles(Path.GetTempPath(), "MSBuild_*.txt"); + + var newFilesCount = newFiles.Length; + if (newFilesCount > _originalFiles.Length) + { + foreach (var file in newFiles.Except(_originalFiles).Select(f => new FileInfo(f))) + { + var contents = File.ReadAllText(file.FullName); + + // Delete the file so we don't pollute the build machine + FileUtilities.DeleteNoThrow(file.FullName); + + // Ignore clean shutdown trace logs. + if (Regex.IsMatch(file.Name, @"MSBuild_NodeShutdown_\d+\.txt") && + Regex.IsMatch(contents, @"Node shutting down with reason BuildComplete and exception:\s*")) + { + newFilesCount--; + continue; + } + + // Com trace file. This is probably fine, but output it as it was likely turned on + // for a reason. + if (Regex.IsMatch(file.Name, @"MSBuild_CommTrace_PID_\d+\.txt")) + { + output.WriteLine($"{file.Name}: {contents}"); + newFilesCount--; + continue; + } + + output.WriteLine($"Build Error File {file.Name}: {contents}"); + } + } + + // Assert file count is equal minus any files that were OK + Assert.Equal(_originalFiles.Length, newFilesCount); + } + } + + public class TransientTempPath : TransientTestState + { + private const string TMP = "TMP"; + private const string TMPDIR = "TMPDIR"; + private const string TEMP = "TEMP"; + + private readonly bool _deleteTempDirectory; + + private readonly TempPaths _oldtempPaths; + + public string TempPath { get; } + + public TransientTempPath(string tempPath, bool deleteTempDirectory) + { + TempPath = tempPath; + _deleteTempDirectory = deleteTempDirectory; + + _oldtempPaths = SetTempPath(tempPath); + } + + private static TempPaths SetTempPath(string tempPath) + { + var oldTempPaths = GetTempPaths(); + + foreach (var key in oldTempPaths.Keys) + { + Environment.SetEnvironmentVariable(key, tempPath); + } + + return oldTempPaths; + } + + private static TempPaths SetTempPaths(TempPaths tempPaths) + { + var oldTempPaths = GetTempPaths(); + + foreach (var key in oldTempPaths.Keys) + { + Environment.SetEnvironmentVariable(key, tempPaths[key]); + } + + return oldTempPaths; + } + + private static TempPaths GetTempPaths() + { + var tempPaths = new TempPaths + { + [TMP] = Environment.GetEnvironmentVariable(TMP), + [TEMP] = Environment.GetEnvironmentVariable(TEMP) + }; + + if (NuGet.Common.RuntimeEnvironmentHelper.IsLinux || NuGet.Common.RuntimeEnvironmentHelper.IsMacOSX) + { + tempPaths[TMPDIR] = Environment.GetEnvironmentVariable(TMPDIR); + } + + return tempPaths; + } + + public override void Revert() + { + SetTempPaths(_oldtempPaths); + + if (_deleteTempDirectory) + { + FileUtilities.DeleteDirectoryNoThrow(TempPath, recursive: true); + } + } + } + + + public class TransientTestFile : TransientTestState + { + private readonly bool _createFile; + private readonly bool _expectedAsOutput; + + public TransientTestFile(string extension, bool createFile, bool expectedAsOutput) + { + _createFile = createFile; + _expectedAsOutput = expectedAsOutput; + Path = FileUtilities.GetTemporaryFile(null, extension, createFile); + } + + public TransientTestFile(string rootPath, string extension, bool createFile, bool expectedAsOutput) + { + _createFile = createFile; + _expectedAsOutput = expectedAsOutput; + Path = FileUtilities.GetTemporaryFile(rootPath, extension, createFile); + } + + public TransientTestFile(string rootPath, string fileNameWithoutExtension, string extension) + { + Path = System.IO.Path.Combine(rootPath, fileNameWithoutExtension + extension); + + File.WriteAllText(Path, string.Empty); + } + + public string Path { get; } + + public override void Revert() + { + try + { + if (_expectedAsOutput) + { + Assert.True(File.Exists(Path), $"A file expected as an output does not exist: {Path}"); + } + } + finally + { + FileUtilities.DeleteNoThrow(Path); + } + } + } + + public class TransientTestFolder : TransientTestState + { + public TransientTestFolder(string folderPath = null, bool createFolder = true) + { + FolderPath = folderPath ?? FileUtilities.GetTemporaryDirectory(createFolder); + + if (createFolder) + { + Directory.CreateDirectory(FolderPath); + } + } + + public string FolderPath { get; } + + public override void Revert() + { + // Basic checks to make sure we're not deleting something very obviously wrong (e.g. + // the entire temp drive). + Assert.NotNull(FolderPath); + Assert.NotEqual(string.Empty, FolderPath); + Assert.NotEqual(@"\", FolderPath); + Assert.NotEqual(@"/", FolderPath); + Assert.NotEqual(Path.GetFullPath(Path.GetTempPath()), Path.GetFullPath(FolderPath)); + Assert.True(Path.IsPathRooted(FolderPath)); + + FileUtilities.DeleteDirectoryNoThrow(FolderPath, true); + } + } + + public class TransientTestEnvironmentVariable : TransientTestState + { + private readonly string _environmentVariableName; + private readonly string _originalValue; + + public TransientTestEnvironmentVariable(string environmentVariableName, string newValue) + { + _environmentVariableName = environmentVariableName; + _originalValue = Environment.GetEnvironmentVariable(environmentVariableName); + + Environment.SetEnvironmentVariable(environmentVariableName, newValue); + } + + public override void Revert() + { + Environment.SetEnvironmentVariable(_environmentVariableName, _originalValue); + } + } + + public class TransientWorkingDirectory : TransientTestState + { + private readonly string _originalValue; + + public TransientWorkingDirectory(string newWorkingDirectory) + { + _originalValue = Directory.GetCurrentDirectory(); + Directory.SetCurrentDirectory(newWorkingDirectory); + } + + public override void Revert() + { + Directory.SetCurrentDirectory(_originalValue); + } + } + + public class TransientTestProjectWithFiles : TransientTestState + { + private readonly TransientTestFolder _folder; + + public string TestRoot => _folder.FolderPath; + + public string[] CreatedFiles { get; } + + public string ProjectFile { get; } + + public TransientTestProjectWithFiles(string projectContents, string[] files, + string relativePathFromRootToProject = ".") + { + _folder = new TransientTestFolder(); + + var projectDir = Path.Combine(TestRoot, relativePathFromRootToProject); + Directory.CreateDirectory(projectDir); + + ProjectFile = Path.Combine(projectDir, "build.proj"); + File.WriteAllText(ProjectFile, FileUtilities.CleanupFileContents(projectContents)); + + CreatedFiles = FileUtilities.CreateFilesInDirectory(TestRoot, files); + } + + public override void Revert() + { + _folder.Revert(); + } + } + + public static class FileUtilities + { + internal static void DeleteDirectoryNoThrow(string path, bool recursive, int retryCount = 0, int retryTimeOut = 0) + { + // Try parse will set the out parameter to 0 if the string passed in is null, or is outside the range of an int. + if (!int.TryParse(Environment.GetEnvironmentVariable("MSBUILDDIRECTORYDELETERETRYCOUNT"), out retryCount)) + { + retryCount = 0; + } + + if (!int.TryParse(Environment.GetEnvironmentVariable("MSBUILDDIRECTORYDELETRETRYTIMEOUT"), out retryTimeOut)) + { + retryTimeOut = 0; + } + + retryCount = retryCount < 1 ? 2 : retryCount; + retryTimeOut = retryTimeOut < 1 ? 500 : retryTimeOut; + + path = FixFilePath(path); + + for (var i = 0; i < retryCount; i++) + { + try + { + if (Directory.Exists(path)) + { + Directory.Delete(path, recursive); + break; + } + } + catch (Exception ex) when (IsIoRelatedException(ex)) + { + } + + if (i + 1 < retryCount) // should not wait for the final iteration since we not gonna check anyway + { + Thread.Sleep(retryTimeOut); + } + } + } + + internal static string FixFilePath(string path) + { + return string.IsNullOrEmpty(path) || Path.DirectorySeparatorChar == '\\' ? path : path.Replace('\\', '/');//.Replace("//", "/"); + } + + /// + /// Generates a unique directory name in the temporary folder. + /// Caller must delete when finished. + /// + /// + internal static string GetTemporaryDirectory(bool createDirectory = true) + { + var temporaryDirectory = Path.Combine(Path.GetTempPath(), "Temporary" + Guid.NewGuid().ToString("N")); + + if (createDirectory) + { + Directory.CreateDirectory(temporaryDirectory); + } + + return temporaryDirectory; + } + + /// + /// A variation on File.Delete that will throw ExceptionHandling.NotExpectedException exceptions + /// + internal static void DeleteNoThrow(string path) + { + try + { + File.Delete(FixFilePath(path)); + } + catch (Exception ex) when (IsIoRelatedException(ex)) + { + } + } + + /// + /// Generates a unique temporary file name with a given extension in the temporary folder. + /// If no extension is provided, uses ".tmp". + /// File is guaranteed to be unique. + /// Caller must delete it when finished. + /// + internal static string GetTemporaryFile() + { + return GetTemporaryFile(".tmp"); + } + + /// + /// Generates a unique temporary file name with a given extension in the temporary folder. + /// File is guaranteed to be unique. + /// Extension may have an initial period. + /// Caller must delete it when finished. + /// May throw IOException. + /// + internal static string GetTemporaryFile(string extension) + { + return GetTemporaryFile(null, extension); + } + + /// + /// Creates a file with unique temporary file name with a given extension in the specified folder. + /// File is guaranteed to be unique. + /// Extension may have an initial period. + /// If folder is null, the temporary folder will be used. + /// Caller must delete it when finished. + /// May throw IOException. + /// + internal static string GetTemporaryFile(string directory, string extension, bool createFile = true) + { + if (extension[0] != '.') + { + extension = '.' + extension; + } + + directory = directory ?? Path.GetTempPath(); + + Directory.CreateDirectory(directory); + + var file = Path.Combine(directory, $"tmp{Guid.NewGuid():N}{extension}"); + + if (createFile) + { + File.WriteAllText(file, string.Empty); + } + + return file; + } + + internal static bool IsIoRelatedException(Exception e) + { + // These all derive from IOException + // DirectoryNotFoundException + // DriveNotFoundException + // EndOfStreamException + // FileLoadException + // FileNotFoundException + // PathTooLongException + // PipeException + return e is UnauthorizedAccessException + || e is NotSupportedException + || (e is ArgumentException && !(e is ArgumentNullException)) + || e is SecurityException + || e is IOException; + } + + /// + /// Does certain replacements in a string representing the project file contents. + /// This makes it easier to write unit tests because the author doesn't have + /// to worry about escaping double-quotes, etc. + /// + /// + /// + internal static string CleanupFileContents(string projectFileContents) + { + // Replace reverse-single-quotes with double-quotes. + projectFileContents = projectFileContents.Replace("`", "\""); + + // Place the correct MSBuild namespace into the tag. + projectFileContents = projectFileContents.Replace("msbuildnamespace", "http://schemas.microsoft.com/developer/msbuild/2003"); + projectFileContents = projectFileContents.Replace("msbuilddefaulttoolsversion", "15.0"); + projectFileContents = projectFileContents.Replace("msbuildassemblyversion", "15.1"); + + return projectFileContents; + } + + /// + /// Creates a bunch of temporary files in the given directory with the specified names and returns + /// their full paths (so they can ultimately be cleaned up) + /// + internal static string[] CreateFilesInDirectory(string rootDirectory, params string[] files) + { + if (files == null) + { + return null; + } + + Assert.True(Directory.Exists(rootDirectory), $"Directory {rootDirectory} does not exist"); + + var result = new string[files.Length]; + + for (var i = 0; i < files.Length; i++) + { + // On Unix there is the risk of creating one file with '\' in its name instead of directories. + // Therefore split the arguments into path fragments and recompose the path. + var fileFragments = SplitPathIntoFragments(files[i]); + var rootDirectoryFragments = SplitPathIntoFragments(rootDirectory); + var pathFragments = rootDirectoryFragments.Concat(fileFragments); + + var fullPath = Path.Combine(pathFragments.ToArray()); + + var directoryName = Path.GetDirectoryName(fullPath); + + Directory.CreateDirectory(directoryName); + Assert.True(Directory.Exists(directoryName)); + + File.WriteAllText(fullPath, string.Empty); + Assert.True(File.Exists(fullPath)); + + result[i] = fullPath; + } + + return result; + } + + private static string[] SplitPathIntoFragments(string path) + { + // Both Path.AltDirectorSeparatorChar and Path.DirectorySeparator char return '/' on OSX, + // which renders them useless for the following case where I want to split a path that may contain either separator + var splits = path.Split('/', '\\'); + + // if the path is rooted then the first split is either empty (Unix) or 'c:' (Windows) + // in this case the root must be restored back to '/' (Unix) or 'c:\' (Windows) + if (Path.IsPathRooted(path)) + { + splits[0] = Path.GetPathRoot(path); + } + + return splits; + } + } + + #region MockLogger + /* + * Class: MockLogger + * + * Mock logger class. Keeps track of errors and warnings and also builds + * up a raw string (fullLog) that contains all messages, warnings, errors. + * + */ + internal sealed class MockLogger : ILogger + { + #region Properties + + private StringBuilder _fullLog = new StringBuilder(); + private readonly ITestOutputHelper _testOutputHelper; + + /// + /// Should the build finished event be logged in the log file. This is to work around the fact we have different + /// localized strings between env and xmake for the build finished event. + /// + internal bool LogBuildFinished { get; set; } = true; + + /* + * Method: ErrorCount + * + * The count of all errors seen so far. + * + */ + internal int ErrorCount { get; private set; } = 0; + + /* + * Method: WarningCount + * + * The count of all warnings seen so far. + * + */ + internal int WarningCount { get; private set; } = 0; + + /// + /// Return the list of logged errors + /// + internal List Errors { get; } = new List(); + + /// + /// Returns the list of logged warnings + /// + internal List Warnings { get; } = new List(); + + /// + /// When set to true, allows task crashes to be logged without causing an assert. + /// + internal bool AllowTaskCrashes + { + get; + set; + } + + /// + /// List of ExternalProjectStarted events + /// + internal List ExternalProjectStartedEvents { get; } = new List(); + + /// + /// List of ExternalProjectFinished events + /// + internal List ExternalProjectFinishedEvents { get; } = new List(); + + /// + /// List of ProjectStarted events + /// + internal List ProjectStartedEvents { get; } = new List(); + + /// + /// List of ProjectFinished events + /// + internal List ProjectFinishedEvents { get; } = new List(); + + /// + /// List of TargetStarted events + /// + internal List TargetStartedEvents { get; } = new List(); + + /// + /// List of TargetFinished events + /// + internal List TargetFinishedEvents { get; } = new List(); + + /// + /// List of TaskStarted events + /// + internal List TaskStartedEvents { get; } = new List(); + + /// + /// List of TaskFinished events + /// + internal List TaskFinishedEvents { get; } = new List(); + + /// + /// List of BuildMessage events + /// + internal List BuildMessageEvents { get; } = new List(); + + /// + /// List of BuildStarted events, thought we expect there to only be one, a valid check is to make sure this list is length 1 + /// + internal List BuildStartedEvents { get; } = new List(); + + /// + /// List of BuildFinished events, thought we expect there to only be one, a valid check is to make sure this list is length 1 + /// + internal List BuildFinishedEvents { get; } = new List(); + + internal List AllBuildEvents { get; } = new List(); + + /* + * Method: FullLog + * + * The raw concatenation of all messages, errors and warnings seen so far. + * + */ + internal string FullLog => _fullLog.ToString(); + + #endregion + + #region Minimal ILogger implementation + + /* + * Property: Verbosity + * + * The level of detail to show in the event log. + * + */ + public LoggerVerbosity Verbosity + { + get => LoggerVerbosity.Normal; + set {/* do nothing */} + } + + /* + * Property: Parameters + * + * The mock logger does not take parameters. + * + */ + public string Parameters + { + get => null; + + set + { + // do nothing + } + } + + /* + * Method: Initialize + * + * Add a new build event. + * + */ + public void Initialize(IEventSource eventSource) + { + eventSource.AnyEventRaised += LoggerEventHandler; + } + + /// + /// Clears the content of the log "file" + /// + public void ClearLog() + { + _fullLog = new StringBuilder(); + } + + /* + * Method: Shutdown + * + * The mock logger does not need to release any resources. + * + */ + public void Shutdown() + { + // do nothing + } + #endregion + + public MockLogger() + { + _testOutputHelper = null; + } + + public MockLogger(ITestOutputHelper testOutputHelper) + { + _testOutputHelper = testOutputHelper; + } + + public List> AdditionalHandlers { get; set; } = new List>(); + + /* + * Method: LoggerEventHandler + * + * Receives build events and logs them the way we like. + * + */ + internal void LoggerEventHandler(object sender, BuildEventArgs eventArgs) + { + AllBuildEvents.Add(eventArgs); + + foreach (var handler in AdditionalHandlers) + { + handler(sender, eventArgs); + } + + if (eventArgs is BuildWarningEventArgs) + { + var w = (BuildWarningEventArgs)eventArgs; + + // hack: disregard the MTA warning. + // need the second condition to pass on ploc builds + if (w.Code != "MSB4056" && !w.Message.Contains("MSB4056")) + { + var logMessage = + $"{w.File}({w.LineNumber},{w.ColumnNumber}): {w.Subcategory} warning {w.Code}: {w.Message}"; + + _fullLog.AppendLine(logMessage); + _testOutputHelper?.WriteLine(logMessage); + + ++WarningCount; + Warnings.Add(w); + } + } + else if (eventArgs is BuildErrorEventArgs) + { + var e = (BuildErrorEventArgs)eventArgs; + + var logMessage = + $"{e.File}({e.LineNumber},{e.ColumnNumber}): {e.Subcategory} error {e.Code}: {e.Message}"; + _fullLog.AppendLine(logMessage); + _testOutputHelper?.WriteLine(logMessage); + + ++ErrorCount; + Errors.Add(e); + } + else + { + // Log the message unless we are a build finished event and logBuildFinished is set to false. + var logMessage = !(eventArgs is BuildFinishedEventArgs) || + (eventArgs is BuildFinishedEventArgs && LogBuildFinished); + if (logMessage) + { + _fullLog.AppendLine(eventArgs.Message); + _testOutputHelper?.WriteLine(eventArgs.Message); + } + } + + if (eventArgs is ExternalProjectStartedEventArgs) + { + ExternalProjectStartedEvents.Add((ExternalProjectStartedEventArgs)eventArgs); + } + else if (eventArgs is ExternalProjectFinishedEventArgs) + { + ExternalProjectFinishedEvents.Add((ExternalProjectFinishedEventArgs)eventArgs); + } + + if (eventArgs is ProjectStartedEventArgs) + { + ProjectStartedEvents.Add((ProjectStartedEventArgs)eventArgs); + } + else if (eventArgs is ProjectFinishedEventArgs) + { + ProjectFinishedEvents.Add((ProjectFinishedEventArgs)eventArgs); + } + else if (eventArgs is TargetStartedEventArgs) + { + TargetStartedEvents.Add((TargetStartedEventArgs)eventArgs); + } + else if (eventArgs is TargetFinishedEventArgs) + { + TargetFinishedEvents.Add((TargetFinishedEventArgs)eventArgs); + } + else if (eventArgs is TaskStartedEventArgs) + { + TaskStartedEvents.Add((TaskStartedEventArgs)eventArgs); + } + else if (eventArgs is TaskFinishedEventArgs) + { + TaskFinishedEvents.Add((TaskFinishedEventArgs)eventArgs); + } + else if (eventArgs is BuildMessageEventArgs) + { + BuildMessageEvents.Add((BuildMessageEventArgs)eventArgs); + } + else if (eventArgs is BuildStartedEventArgs) + { + BuildStartedEvents.Add((BuildStartedEventArgs)eventArgs); + } + else if (eventArgs is BuildFinishedEventArgs) + { + BuildFinishedEvents.Add((BuildFinishedEventArgs)eventArgs); + + if (!AllowTaskCrashes) + { + // We should not have any task crashes. Sometimes a test will validate that their expected error + // code appeared, but not realize it then crashed. + AssertLogDoesntContain("MSB4018"); + } + + // We should not have any Engine crashes. + AssertLogDoesntContain("MSB0001"); + + // Console.Write in the context of a unit test is very expensive. A hundred + // calls to Console.Write can easily take two seconds on a fast machine. Therefore, only + // do the Console.Write once at the end of the build. + Console.Write(FullLog); + } + } + + /// + /// Assert that the log file contains the given strings, in order. + /// + /// + internal void AssertLogContains(params string[] contains) + { + AssertLogContains(true, contains); + } + + /// + /// Assert that the log file contains the given string, in order. Includes the option of case invariance + /// + /// False if we do not care about case sensitivity + /// + internal void AssertLogContains(bool isCaseSensitive, params string[] contains) + { + var reader = new StringReader(FullLog); + var index = 0; + + var currentLine = reader.ReadLine(); + if (!isCaseSensitive) + { + currentLine = currentLine.ToUpper(); + } + + while (currentLine != null) + { + var comparer = contains[index]; + if (!isCaseSensitive) + { + comparer = comparer.ToUpper(); + } + + if (currentLine.Contains(comparer)) + { + index++; + if (index == contains.Length) break; + } + + currentLine = reader.ReadLine(); + if (!isCaseSensitive) + { + currentLine = currentLine?.ToUpper(); + } + } + if (index != contains.Length) + { + if (_testOutputHelper != null) + { + _testOutputHelper.WriteLine(FullLog); + } + else + { + Console.WriteLine(FullLog); + } + Assert.True(false, $"Log was expected to contain '{contains[index]}', but did not.\n=======\n{FullLog}\n======="); + } + } + + /// + /// Assert that the log file contains the given string. + /// + /// + internal void AssertLogDoesntContain(string contains) + { + if (FullLog.Contains(contains)) + { + if (_testOutputHelper != null) + { + _testOutputHelper.WriteLine(FullLog); + } + else + { + Console.WriteLine(FullLog); + } + Assert.True(false, $"Log was not expected to contain '{contains}', but did."); + } + } + + /// + /// Assert that no errors were logged + /// + internal void AssertNoErrors() + { + Assert.Equal(0, ErrorCount); + } + + /// + /// Assert that no warnings were logged + /// + internal void AssertNoWarnings() + { + Assert.Equal(0, WarningCount); + } + } +#endregion +}