| #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 |
| { |
| /// <summary> |
| /// Tests for Grpc.Tools MSBuild .target and .props files. |
| /// </summary> |
| /// <remarks> |
| /// 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. |
| /// <para> |
| /// 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. |
| /// </para> |
| /// </remarks> |
| [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(); |
| expectedFiles.Add("file.proto", "File.cs", "FileGrpc.cs") |
| .Add("protos/another.proto", "Another.cs", "AnotherGrpc.cs") |
| .Add("second.proto", "Second.cs", "SecondGrpc.cs") |
| // Test duplicate name under different directories is allowed. |
| // See https://github.com/grpc/grpc/issues/17672 |
| .Add("protos/file.proto", "File.cs", "FileGrpc.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()); |
| } |
| |
| [Test] |
| public void TestCharactersInName() |
| { |
| // see https://github.com/grpc/grpc/issues/17661 - dot in name |
| // and https://github.com/grpc/grpc/issues/18698 - numbers in name |
| SetUpForTest(nameof(TestCharactersInName)); |
| |
| var expectedFiles = new ExpectedFilesBuilder(); |
| expectedFiles.Add("protos/hello.world.proto", "HelloWorld.cs", "Hello.worldGrpc.cs"); |
| expectedFiles.Add("protos/m_double_2d.proto", "MDouble2D.cs", "MDouble2dGrpc.cs"); |
| |
| TryRunMsBuild("TestCharactersInName", expectedFiles.ToString()); |
| } |
| |
| [Test] |
| public void TestExtraOptions() |
| { |
| // Test various extra options passed to protoc and plugin |
| // See https://github.com/grpc/grpc/issues/25950 |
| // Tests setting AdditionalProtocArguments, OutputOptions and GrpcOutputOptions |
| SetUpForTest(nameof(TestExtraOptions)); |
| |
| var expectedFiles = new ExpectedFilesBuilder(); |
| expectedFiles.Add("file.proto", "File.cs", "FileGrpc.cs"); |
| |
| TryRunMsBuild("TestExtraOptions", expectedFiles.ToString()); |
| } |
| |
| [Test] |
| public void TestGrpcServicesMetadata() |
| { |
| // Test different values for GrpcServices item metadata |
| SetUpForTest(nameof(TestGrpcServicesMetadata)); |
| |
| var expectedFiles = new ExpectedFilesBuilder(); |
| expectedFiles.Add("messages.proto", "Messages.cs"); |
| expectedFiles.Add("serveronly.proto", "Serveronly.cs", "ServeronlyGrpc.cs"); |
| expectedFiles.Add("clientonly.proto", "Clientonly.cs", "ClientonlyGrpc.cs"); |
| expectedFiles.Add("clientandserver.proto", "Clientandserver.cs", "ClientandserverGrpc.cs"); |
| |
| TryRunMsBuild("TestGrpcServicesMetadata", expectedFiles.ToString()); |
| } |
| |
| [Test] |
| public void TestSetOutputDirs() |
| { |
| // Test setting different GrpcOutputDir and OutputDir |
| SetUpForTest(nameof(TestSetOutputDirs)); |
| |
| var expectedFiles = new ExpectedFilesBuilder(); |
| expectedFiles.Add("file.proto", "File.cs", "FileGrpc.cs"); |
| |
| TryRunMsBuild("TestSetOutputDirs", expectedFiles.ToString()); |
| } |
| |
| /// <summary> |
| /// Set up common paths for all the tests |
| /// </summary> |
| 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")); |
| } |
| |
| |
| /// <summary> |
| /// Normalize path string to use just forward slashes. That makes it easier to compare paths |
| /// for equality in the tests. |
| /// </summary> |
| private string NormalizePath(string path) |
| { |
| return path.Replace('\\','/'); |
| } |
| |
| /// <summary> |
| /// Set up test specific paths |
| /// </summary> |
| /// <param name="testName">Name of the test</param> |
| /// <param name="testPath">Optional path to the test project</param> |
| 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)); |
| } |
| |
| /// <summary> |
| /// Run "dotnet build" on the test's project file. |
| /// </summary> |
| /// <param name="testName">Name of test and name of directory containing the test</param> |
| /// <param name="filesToGenerate">Tell the fake protoc script which files to generate</param> |
| /// <param name="testId">A unique ID for the test run - used to create results file</param> |
| 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); |
| } |
| |
| /// <summary> |
| /// Run the "dotnet build" command |
| /// </summary> |
| /// <param name="args">arguments to the dotnet command</param> |
| /// <param name="workingDirectory">working directory</param> |
| /// <param name="envVariables">environment variables to set</param> |
| 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."); |
| } |
| } |
| |
| /// <summary> |
| /// Compare the JSON results to the expected results |
| /// </summary> |
| /// <param name="expected"></param> |
| /// <param name="actual"></param> |
| 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); |
| } |
| |
| /// <summary> |
| /// Helper class for formatting the string specifying the list of proto files and |
| /// the expected generated files for each proto file. |
| /// </summary> |
| public class ExpectedFilesBuilder |
| { |
| private readonly List<string> protoAndFiles = new List<string>(); |
| |
| 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()); |
| } |
| } |
| |
| /// <summary> |
| /// Hold the JSON results |
| /// </summary> |
| public class Results |
| { |
| /// <summary> |
| /// JSON "Metadata" |
| /// </summary> |
| public Dictionary<string, string> Metadata { get; set; } |
| |
| /// <summary> |
| /// JSON "Files" |
| /// </summary> |
| public Dictionary<string, Dictionary<string, List<string>>> Files { get; set; } |
| |
| /// <summary> |
| /// Read a JSON file |
| /// </summary> |
| /// <param name="filepath"></param> |
| /// <returns></returns> |
| 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; |
| } |
| } |
| |
| /// <summary> |
| /// Get the proto file names from the JSON |
| /// </summary> |
| public SortedSet<string> ProtoFiles => new SortedSet<string>(Files.Keys); |
| |
| /// <summary> |
| /// Get the protoc arguments for the associated proto file |
| /// </summary> |
| /// <param name="protofile"></param> |
| /// <returns></returns> |
| public SortedSet<string> GetArgumentNames(string protofile) |
| { |
| Dictionary<string, List<string>> args; |
| if (Files.TryGetValue(protofile, out args)) |
| { |
| return new SortedSet<string>(args.Keys); |
| } |
| else |
| { |
| return new SortedSet<string>(); |
| } |
| } |
| |
| /// <summary> |
| /// Get the values for the named argument for the proto file |
| /// </summary> |
| /// <param name="protofile">proto file</param> |
| /// <param name="name">argument</param> |
| /// <returns></returns> |
| public List<string> GetArgumentValues(string protofile, string name) |
| { |
| Dictionary<string, List<string>> args; |
| if (Files.TryGetValue(protofile, out args)) |
| { |
| List<string> values; |
| if (args.TryGetValue(name, out values)) |
| { |
| return new List<string>(values); |
| } |
| } |
| return new List<string>(); |
| } |
| } |
| } |
| |
| |
| } |