| #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>(); | |
| } | |
| } | |
| } | |
| } |