#region Copyright notice and license // Copyright 2022 The gRPC Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. #endregion using System; using System.IO; using NUnit.Framework; using System.Diagnostics; using System.Reflection; using System.Collections.Specialized; using System.Collections; using System.Collections.Generic; using System.Text.RegularExpressions; using Newtonsoft.Json; namespace Grpc.Tools.Tests { /// /// Tests for Grpc.Tools MSBuild .target and .props files. /// /// /// The Grpc.Tools NuGet package is not tested directly, but instead the /// same .target and .props files are included in a MSBuild project and /// that project is built using "dotnet build" with the SDK installed on /// the test machine. /// /// The real protoc compiler is not called. Instead a fake protoc script is /// called that does the minimum work needed for the build to succeed /// (generating cs files and writing dependencies file) and also writes out /// the arguments it was called with in a JSON file. The output is checked /// with expected results. /// /// [TestFixture] public class MsBuildIntegrationTest { private const string TASKS_ASSEMBLY_PROPERTY = "_Protobuf_MsBuildAssembly"; private const string TASKS_ASSEMBLY_DLL = "Protobuf.MSBuild.dll"; private const string PROTBUF_FULLPATH_PROPERTY = "Protobuf_ProtocFullPath"; private const string PLUGIN_FULLPATH_PROPERTY = "gRPC_PluginFullPath"; private const string TOOLS_BUILD_DIR_PROPERTY = "GrpcToolsBuildDir"; private const string MSBUILD_LOG_VERBOSITY = "diagnostic"; // "diagnostic" or "detailed" private string testId; private string fakeProtoc; private string grpcToolsBuildDir; private string tasksAssembly; private string testDataDir; private string testProjectDir; private string testOutBaseDir; private string testOutDir; [SetUp] public void InitTest() { #if NET45 // We need to run these tests for one framework. // This test class is just a driver for calling the // "dotnet build" processes, so it doesn't matter what // the runtime of this class actually is. Assert.Ignore("Skipping test when NET45"); #endif } [Test] public void TestSingleProto() { SetUpForTest(nameof(TestSingleProto)); var expectedFiles = new ExpectedFilesBuilder(); expectedFiles.Add("file.proto", "File.cs", "FileGrpc.cs"); TryRunMsBuild("TestSingleProto", expectedFiles.ToString()); } [Test] public void TestMultipleProtos() { SetUpForTest(nameof(TestMultipleProtos)); var expectedFiles = new ExpectedFilesBuilder(); // TODO(jtattermusch): add test that "duplicate" .proto file // name (under different directories) is allowed. See https://github.com/grpc/grpc/issues/17672 expectedFiles.Add("file.proto", "File.cs", "FileGrpc.cs") .Add("protos/another.proto", "Another.cs", "AnotherGrpc.cs") .Add("second.proto", "Second.cs", "SecondGrpc.cs"); TryRunMsBuild("TestMultipleProtos", expectedFiles.ToString()); } [Test] public void TestAtInPath() { SetUpForTest(nameof(TestAtInPath)); var expectedFiles = new ExpectedFilesBuilder(); expectedFiles.Add("@protos/file.proto", "File.cs", "FileGrpc.cs"); TryRunMsBuild("TestAtInPath", expectedFiles.ToString()); } [Test] public void TestProtoOutsideProject() { SetUpForTest(nameof(TestProtoOutsideProject), "TestProtoOutsideProject/project"); var expectedFiles = new ExpectedFilesBuilder(); expectedFiles.Add("../api/greet.proto", "Greet.cs", "GreetGrpc.cs"); TryRunMsBuild("TestProtoOutsideProject/project", expectedFiles.ToString()); } /// /// Set up common paths for all the tests /// private void SetUpCommonPaths() { var assemblyDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); testDataDir = Path.GetFullPath($"{assemblyDir}/../../../IntegrationTests"); // Path for fake proto. // On Windows we have to wrap the python script in a BAT script since we can only // pass one executable name without parameters to the MSBuild // - e.g. we can't give "python fakeprotoc.py" var fakeProtocScript = Platform.IsWindows ? "fakeprotoc.bat" : "fakeprotoc.py"; fakeProtoc = Path.GetFullPath($"{assemblyDir}/../../../scripts/{fakeProtocScript}"); // Path for "build" directory under Grpc.Tools grpcToolsBuildDir = Path.GetFullPath($"{assemblyDir}/../../../../Grpc.Tools/build"); // Task assembly is needed to run the extension tasks // We use the assembly that was copied next to Grpc.Tools.Tests.dll // as a Grpc.Tools.Tests dependency since we know it's the correct one // and we don't have to figure out its original path (which is different // for debug/release builds etc). tasksAssembly = Path.Combine(assemblyDir, TASKS_ASSEMBLY_DLL); // put test ouptput directory outside of Grpc.Tools.Tests to avoid problems with // repeated builds. testOutBaseDir = NormalizePath(Path.GetFullPath($"{assemblyDir}/../../../../test-out/grpc_tools_integration_tests")); } /// /// Normalize path string to use just forward slashes. That makes it easier to compare paths /// for equality in the tests. /// private string NormalizePath(string path) { return path.Replace('\\','/'); } /// /// Set up test specific paths /// /// Name of the test /// Optional path to the test project private void SetUpForTest(string testName, string testPath = null) { if (testPath == null) { testPath = testName; } SetUpCommonPaths(); testId = $"{testName}_run-{Guid.NewGuid().ToString()}"; Console.WriteLine($"TestID for test: {testId}"); // Paths for test data testProjectDir = NormalizePath(Path.Combine(testDataDir, testPath)); testOutDir = NormalizePath(Path.Combine(testOutBaseDir, testId)); } /// /// Run "dotnet build" on the test's project file. /// /// Name of test and name of directory containing the test /// Tell the fake protoc script which files to generate /// A unique ID for the test run - used to create results file private void TryRunMsBuild(string testName, string filesToGenerate) { Directory.CreateDirectory(testOutDir); // create the arguments for the "dotnet build" var args = $"build -p:{TASKS_ASSEMBLY_PROPERTY}={tasksAssembly}" + $" -p:TestOutDir={testOutDir}" + $" -p:BaseOutputPath={testOutDir}/bin/" + $" -p:BaseIntermediateOutputPath={testOutDir}/obj/" + $" -p:{TOOLS_BUILD_DIR_PROPERTY}={grpcToolsBuildDir}" + $" -p:{PROTBUF_FULLPATH_PROPERTY}={fakeProtoc}" + $" -p:{PLUGIN_FULLPATH_PROPERTY}=dummy-plugin-not-used" + $" -fl -flp:LogFile={testOutDir}/log/msbuild.log;verbosity={MSBUILD_LOG_VERBOSITY}" + $" msbuildtest.csproj"; // To pass additional parameters to fake protoc process // we need to use environment variables var envVariables = new StringDictionary { { "FAKEPROTOC_PROJECTDIR", testProjectDir }, { "FAKEPROTOC_OUTDIR", testOutDir }, { "FAKEPROTOC_GENERATE_EXPECTED", filesToGenerate }, { "FAKEPROTOC_TESTID", testId } }; // Run the "dotnet build" ProcessMsbuild(args, testProjectDir, envVariables); // Check the results JSON matches the expected JSON Results actualResults = Results.Read(testOutDir + "/log/results.json"); Results expectedResults = Results.Read(testProjectDir + "/expected.json"); CompareResults(expectedResults, actualResults); } /// /// Run the "dotnet build" command /// /// arguments to the dotnet command /// working directory /// environment variables to set private void ProcessMsbuild(string args, string workingDirectory, StringDictionary envVariables) { using (var process = new Process()) { process.StartInfo.FileName = "dotnet"; process.StartInfo.Arguments = args; process.StartInfo.RedirectStandardOutput = true; process.StartInfo.RedirectStandardError = true; process.StartInfo.WorkingDirectory = workingDirectory; process.StartInfo.UseShellExecute = false; StringDictionary procEnv = process.StartInfo.EnvironmentVariables; foreach (DictionaryEntry entry in envVariables) { if (!procEnv.ContainsKey((string)entry.Key)) { procEnv.Add((string)entry.Key, (string)entry.Value); } } process.OutputDataReceived += (sender, e) => { if (e.Data != null) { Console.WriteLine(e.Data); } }; process.ErrorDataReceived += (sender, e) => { if (e.Data != null) { Console.WriteLine(e.Data); } }; process.Start(); process.BeginErrorReadLine(); process.BeginOutputReadLine(); process.WaitForExit(); Assert.AreEqual(0, process.ExitCode, "The dotnet/msbuild subprocess invocation exited with non-zero exitcode."); } } /// /// Compare the JSON results to the expected results /// /// /// private void CompareResults(Results expected, Results actual) { // Check set of .proto files processed is the same var protofiles = expected.ProtoFiles; CollectionAssert.AreEquivalent(protofiles, actual.ProtoFiles, "Set of .proto files being processed must match."); // check protoc arguments foreach (string protofile in protofiles) { var expectedArgs = expected.GetArgumentNames(protofile); var actualArgs = actual.GetArgumentNames(protofile); CollectionAssert.AreEquivalent(expectedArgs, actualArgs, $"Set of protoc arguments used for {protofile} must match."); // Check the values. // Any value with: // - IGNORE: - will not be compared but must exist // - REGEX: - compare using a regular expression // - anything else is an exact match // Expected results can also have tokens that are replaced before comparing: // - ${TEST_OUT_DIR} - the test output directory foreach (string argname in expectedArgs) { var expectedValues = expected.GetArgumentValues(protofile, argname); var actualValues = actual.GetArgumentValues(protofile, argname); Assert.AreEqual(expectedValues.Count, actualValues.Count, $"{protofile}: Wrong number of occurrences of argument '{argname}'"); // Since generally the order of arguments on the commandline is important, // it is fair to compare arguments with expected values one by one. // Most arguments are only used at most once by the msbuild integration anyway. for (int i = 0; i < expectedValues.Count; i++) { var expectedValue = ReplaceTokens(expectedValues[i]); var actualValue = actualValues[i]; if (expectedValue.StartsWith("IGNORE:")) continue; var regexPrefix = "REGEX:"; if (expectedValue.StartsWith(regexPrefix)) { string pattern = expectedValue.Substring(regexPrefix.Length); Assert.IsTrue(Regex.IsMatch(actualValue, pattern), $"{protofile}: Expected value '{expectedValue}' for argument '{argname}'. Actual value: '{actualValue}'"); } else { Assert.AreEqual(expectedValue, actualValue, $"{protofile}: Wrong value for argument '{argname}'"); } } } } } private string ReplaceTokens(string original) { return original .Replace("${TEST_OUT_DIR}", testOutDir); } /// /// Helper class for formatting the string specifying the list of proto files and /// the expected generated files for each proto file. /// public class ExpectedFilesBuilder { private readonly List protoAndFiles = new List(); public ExpectedFilesBuilder Add(string protoFile, params string[] files) { protoAndFiles.Add(protoFile + ":" + string.Join(";", files)); return this; } public override string ToString() { return string.Join("|", protoAndFiles.ToArray()); } } /// /// Hold the JSON results /// public class Results { /// /// JSON "Metadata" /// public Dictionary Metadata { get; set; } /// /// JSON "Files" /// public Dictionary>> Files { get; set; } /// /// Read a JSON file /// /// /// public static Results Read(string filepath) { using (StreamReader file = File.OpenText(filepath)) { JsonSerializer serializer = new JsonSerializer(); Results results = (Results)serializer.Deserialize(file, typeof(Results)); return results; } } /// /// Get the proto file names from the JSON /// public SortedSet ProtoFiles => new SortedSet(Files.Keys); /// /// Get the protoc arguments for the associated proto file /// /// /// public SortedSet GetArgumentNames(string protofile) { Dictionary> args; if (Files.TryGetValue(protofile, out args)) { return new SortedSet(args.Keys); } else { return new SortedSet(); } } /// /// Get the values for the named argument for the proto file /// /// proto file /// argument /// public List GetArgumentValues(string protofile, string name) { Dictionary> args; if (Files.TryGetValue(protofile, out args)) { List values; if (args.TryGetValue(name, out values)) { return new List(values); } } return new List(); } } } }