1 #region Copyright notice and license 2 3 // Copyright 2022 The gRPC Authors 4 // 5 // Licensed under the Apache License, Version 2.0 (the "License"); 6 // you may not use this file except in compliance with the License. 7 // You may obtain a copy of the License at 8 // 9 // http://www.apache.org/licenses/LICENSE-2.0 10 // 11 // Unless required by applicable law or agreed to in writing, software 12 // distributed under the License is distributed on an "AS IS" BASIS, 13 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 // See the License for the specific language governing permissions and 15 // limitations under the License. 16 17 #endregion 18 19 using System; 20 using System.IO; 21 using NUnit.Framework; 22 using System.Diagnostics; 23 using System.Reflection; 24 using System.Collections.Specialized; 25 using System.Collections; 26 using System.Collections.Generic; 27 using System.Text.RegularExpressions; 28 using Newtonsoft.Json; 29 30 namespace Grpc.Tools.Tests 31 { 32 /// <summary> 33 /// Tests for Grpc.Tools MSBuild .target and .props files. 34 /// </summary> 35 /// <remarks> 36 /// The Grpc.Tools NuGet package is not tested directly, but instead the 37 /// same .target and .props files are included in a MSBuild project and 38 /// that project is built using "dotnet build" with the SDK installed on 39 /// the test machine. 40 /// <para> 41 /// The real protoc compiler is not called. Instead a fake protoc script is 42 /// called that does the minimum work needed for the build to succeed 43 /// (generating cs files and writing dependencies file) and also writes out 44 /// the arguments it was called with in a JSON file. The output is checked 45 /// with expected results. 46 /// </para> 47 /// </remarks> 48 [TestFixture] 49 public class MsBuildIntegrationTest 50 { 51 private const string TASKS_ASSEMBLY_PROPERTY = "_Protobuf_MsBuildAssembly"; 52 private const string TASKS_ASSEMBLY_DLL = "Protobuf.MSBuild.dll"; 53 private const string PROTBUF_FULLPATH_PROPERTY = "Protobuf_ProtocFullPath"; 54 private const string PLUGIN_FULLPATH_PROPERTY = "gRPC_PluginFullPath"; 55 private const string TOOLS_BUILD_DIR_PROPERTY = "GrpcToolsBuildDir"; 56 57 private const string MSBUILD_LOG_VERBOSITY = "diagnostic"; // "diagnostic" or "detailed" 58 59 private string testId; 60 private string fakeProtoc; 61 private string grpcToolsBuildDir; 62 private string tasksAssembly; 63 private string testDataDir; 64 private string testProjectDir; 65 private string testOutBaseDir; 66 private string testOutDir; 67 68 [SetUp] InitTest()69 public void InitTest() 70 { 71 #if NET45 72 // We need to run these tests for one framework. 73 // This test class is just a driver for calling the 74 // "dotnet build" processes, so it doesn't matter what 75 // the runtime of this class actually is. 76 Assert.Ignore("Skipping test when NET45"); 77 #endif 78 } 79 80 [Test] TestSingleProto()81 public void TestSingleProto() 82 { 83 SetUpForTest(nameof(TestSingleProto)); 84 85 var expectedFiles = new ExpectedFilesBuilder(); 86 expectedFiles.Add("file.proto", "File.cs", "FileGrpc.cs"); 87 88 TryRunMsBuild("TestSingleProto", expectedFiles.ToString()); 89 } 90 91 [Test] TestMultipleProtos()92 public void TestMultipleProtos() 93 { 94 SetUpForTest(nameof(TestMultipleProtos)); 95 96 var expectedFiles = new ExpectedFilesBuilder(); 97 expectedFiles.Add("file.proto", "File.cs", "FileGrpc.cs") 98 .Add("protos/another.proto", "Another.cs", "AnotherGrpc.cs") 99 .Add("second.proto", "Second.cs", "SecondGrpc.cs") 100 // Test duplicate name under different directories is allowed. 101 // See https://github.com/grpc/grpc/issues/17672 102 .Add("protos/file.proto", "File.cs", "FileGrpc.cs"); 103 104 TryRunMsBuild("TestMultipleProtos", expectedFiles.ToString()); 105 } 106 107 [Test] TestAtInPath()108 public void TestAtInPath() 109 { 110 SetUpForTest(nameof(TestAtInPath)); 111 112 var expectedFiles = new ExpectedFilesBuilder(); 113 expectedFiles.Add("@protos/file.proto", "File.cs", "FileGrpc.cs"); 114 115 TryRunMsBuild("TestAtInPath", expectedFiles.ToString()); 116 } 117 118 [Test] TestProtoOutsideProject()119 public void TestProtoOutsideProject() 120 { 121 SetUpForTest(nameof(TestProtoOutsideProject), "TestProtoOutsideProject/project"); 122 123 var expectedFiles = new ExpectedFilesBuilder(); 124 expectedFiles.Add("../api/greet.proto", "Greet.cs", "GreetGrpc.cs"); 125 126 TryRunMsBuild("TestProtoOutsideProject/project", expectedFiles.ToString()); 127 } 128 129 [Test] TestCharactersInName()130 public void TestCharactersInName() 131 { 132 // see https://github.com/grpc/grpc/issues/17661 - dot in name 133 // and https://github.com/grpc/grpc/issues/18698 - numbers in name 134 SetUpForTest(nameof(TestCharactersInName)); 135 136 var expectedFiles = new ExpectedFilesBuilder(); 137 expectedFiles.Add("protos/hello.world.proto", "HelloWorld.cs", "Hello.worldGrpc.cs"); 138 expectedFiles.Add("protos/m_double_2d.proto", "MDouble2D.cs", "MDouble2dGrpc.cs"); 139 140 TryRunMsBuild("TestCharactersInName", expectedFiles.ToString()); 141 } 142 143 [Test] TestExtraOptions()144 public void TestExtraOptions() 145 { 146 // Test various extra options passed to protoc and plugin 147 // See https://github.com/grpc/grpc/issues/25950 148 // Tests setting AdditionalProtocArguments, OutputOptions and GrpcOutputOptions 149 SetUpForTest(nameof(TestExtraOptions)); 150 151 var expectedFiles = new ExpectedFilesBuilder(); 152 expectedFiles.Add("file.proto", "File.cs", "FileGrpc.cs"); 153 154 TryRunMsBuild("TestExtraOptions", expectedFiles.ToString()); 155 } 156 157 [Test] TestGrpcServicesMetadata()158 public void TestGrpcServicesMetadata() 159 { 160 // Test different values for GrpcServices item metadata 161 SetUpForTest(nameof(TestGrpcServicesMetadata)); 162 163 var expectedFiles = new ExpectedFilesBuilder(); 164 expectedFiles.Add("messages.proto", "Messages.cs"); 165 expectedFiles.Add("serveronly.proto", "Serveronly.cs", "ServeronlyGrpc.cs"); 166 expectedFiles.Add("clientonly.proto", "Clientonly.cs", "ClientonlyGrpc.cs"); 167 expectedFiles.Add("clientandserver.proto", "Clientandserver.cs", "ClientandserverGrpc.cs"); 168 169 TryRunMsBuild("TestGrpcServicesMetadata", expectedFiles.ToString()); 170 } 171 172 [Test] TestSetOutputDirs()173 public void TestSetOutputDirs() 174 { 175 // Test setting different GrpcOutputDir and OutputDir 176 SetUpForTest(nameof(TestSetOutputDirs)); 177 178 var expectedFiles = new ExpectedFilesBuilder(); 179 expectedFiles.Add("file.proto", "File.cs", "FileGrpc.cs"); 180 181 TryRunMsBuild("TestSetOutputDirs", expectedFiles.ToString()); 182 } 183 184 /// <summary> 185 /// Set up common paths for all the tests 186 /// </summary> SetUpCommonPaths()187 private void SetUpCommonPaths() 188 { 189 var assemblyDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); 190 testDataDir = Path.GetFullPath($"{assemblyDir}/../../../IntegrationTests"); 191 192 // Path for fake proto. 193 // On Windows we have to wrap the python script in a BAT script since we can only 194 // pass one executable name without parameters to the MSBuild 195 // - e.g. we can't give "python fakeprotoc.py" 196 var fakeProtocScript = Platform.IsWindows ? "fakeprotoc.bat" : "fakeprotoc.py"; 197 fakeProtoc = Path.GetFullPath($"{assemblyDir}/../../../scripts/{fakeProtocScript}"); 198 199 // Path for "build" directory under Grpc.Tools 200 grpcToolsBuildDir = Path.GetFullPath($"{assemblyDir}/../../../../Grpc.Tools/build"); 201 202 // Task assembly is needed to run the extension tasks 203 // We use the assembly that was copied next to Grpc.Tools.Tests.dll 204 // as a Grpc.Tools.Tests dependency since we know it's the correct one 205 // and we don't have to figure out its original path (which is different 206 // for debug/release builds etc). 207 tasksAssembly = Path.Combine(assemblyDir, TASKS_ASSEMBLY_DLL); 208 209 // put test ouptput directory outside of Grpc.Tools.Tests to avoid problems with 210 // repeated builds. 211 testOutBaseDir = NormalizePath(Path.GetFullPath($"{assemblyDir}/../../../../test-out/grpc_tools_integration_tests")); 212 } 213 214 215 /// <summary> 216 /// Normalize path string to use just forward slashes. That makes it easier to compare paths 217 /// for equality in the tests. 218 /// </summary> NormalizePath(string path)219 private string NormalizePath(string path) 220 { 221 return path.Replace('\\','/'); 222 } 223 224 /// <summary> 225 /// Set up test specific paths 226 /// </summary> 227 /// <param name="testName">Name of the test</param> 228 /// <param name="testPath">Optional path to the test project</param> SetUpForTest(string testName, string testPath = null)229 private void SetUpForTest(string testName, string testPath = null) 230 { 231 if (testPath == null) { 232 testPath = testName; 233 } 234 235 SetUpCommonPaths(); 236 237 testId = $"{testName}_run-{Guid.NewGuid().ToString()}"; 238 Console.WriteLine($"TestID for test: {testId}"); 239 240 // Paths for test data 241 testProjectDir = NormalizePath(Path.Combine(testDataDir, testPath)); 242 testOutDir = NormalizePath(Path.Combine(testOutBaseDir, testId)); 243 } 244 245 /// <summary> 246 /// Run "dotnet build" on the test's project file. 247 /// </summary> 248 /// <param name="testName">Name of test and name of directory containing the test</param> 249 /// <param name="filesToGenerate">Tell the fake protoc script which files to generate</param> 250 /// <param name="testId">A unique ID for the test run - used to create results file</param> TryRunMsBuild(string testName, string filesToGenerate)251 private void TryRunMsBuild(string testName, string filesToGenerate) 252 { 253 Directory.CreateDirectory(testOutDir); 254 255 // create the arguments for the "dotnet build" 256 var args = $"build -p:{TASKS_ASSEMBLY_PROPERTY}={tasksAssembly}" 257 + $" -p:TestOutDir={testOutDir}" 258 + $" -p:BaseOutputPath={testOutDir}/bin/" 259 + $" -p:BaseIntermediateOutputPath={testOutDir}/obj/" 260 + $" -p:{TOOLS_BUILD_DIR_PROPERTY}={grpcToolsBuildDir}" 261 + $" -p:{PROTBUF_FULLPATH_PROPERTY}={fakeProtoc}" 262 + $" -p:{PLUGIN_FULLPATH_PROPERTY}=dummy-plugin-not-used" 263 + $" -fl -flp:LogFile={testOutDir}/log/msbuild.log;verbosity={MSBUILD_LOG_VERBOSITY}" 264 + $" msbuildtest.csproj"; 265 266 // To pass additional parameters to fake protoc process 267 // we need to use environment variables 268 var envVariables = new StringDictionary { 269 { "FAKEPROTOC_PROJECTDIR", testProjectDir }, 270 { "FAKEPROTOC_OUTDIR", testOutDir }, 271 { "FAKEPROTOC_GENERATE_EXPECTED", filesToGenerate }, 272 { "FAKEPROTOC_TESTID", testId } 273 }; 274 275 // Run the "dotnet build" 276 ProcessMsbuild(args, testProjectDir, envVariables); 277 278 // Check the results JSON matches the expected JSON 279 Results actualResults = Results.Read(testOutDir + "/log/results.json"); 280 Results expectedResults = Results.Read(testProjectDir + "/expected.json"); 281 CompareResults(expectedResults, actualResults); 282 } 283 284 /// <summary> 285 /// Run the "dotnet build" command 286 /// </summary> 287 /// <param name="args">arguments to the dotnet command</param> 288 /// <param name="workingDirectory">working directory</param> 289 /// <param name="envVariables">environment variables to set</param> ProcessMsbuild(string args, string workingDirectory, StringDictionary envVariables)290 private void ProcessMsbuild(string args, string workingDirectory, StringDictionary envVariables) 291 { 292 using (var process = new Process()) 293 { 294 process.StartInfo.FileName = "dotnet"; 295 process.StartInfo.Arguments = args; 296 process.StartInfo.RedirectStandardOutput = true; 297 process.StartInfo.RedirectStandardError = true; 298 process.StartInfo.WorkingDirectory = workingDirectory; 299 process.StartInfo.UseShellExecute = false; 300 StringDictionary procEnv = process.StartInfo.EnvironmentVariables; 301 foreach (DictionaryEntry entry in envVariables) 302 { 303 if (!procEnv.ContainsKey((string)entry.Key)) 304 { 305 procEnv.Add((string)entry.Key, (string)entry.Value); 306 } 307 } 308 309 process.OutputDataReceived += (sender, e) => { 310 if (e.Data != null) 311 { 312 Console.WriteLine(e.Data); 313 } 314 }; 315 process.ErrorDataReceived += (sender, e) => { 316 if (e.Data != null) 317 { 318 Console.WriteLine(e.Data); 319 } 320 }; 321 322 process.Start(); 323 324 process.BeginErrorReadLine(); 325 process.BeginOutputReadLine(); 326 327 process.WaitForExit(); 328 Assert.AreEqual(0, process.ExitCode, "The dotnet/msbuild subprocess invocation exited with non-zero exitcode."); 329 } 330 } 331 332 /// <summary> 333 /// Compare the JSON results to the expected results 334 /// </summary> 335 /// <param name="expected"></param> 336 /// <param name="actual"></param> CompareResults(Results expected, Results actual)337 private void CompareResults(Results expected, Results actual) 338 { 339 // Check set of .proto files processed is the same 340 var protofiles = expected.ProtoFiles; 341 CollectionAssert.AreEquivalent(protofiles, actual.ProtoFiles, "Set of .proto files being processed must match."); 342 343 // check protoc arguments 344 foreach (string protofile in protofiles) 345 { 346 var expectedArgs = expected.GetArgumentNames(protofile); 347 var actualArgs = actual.GetArgumentNames(protofile); 348 CollectionAssert.AreEquivalent(expectedArgs, actualArgs, $"Set of protoc arguments used for {protofile} must match."); 349 350 // Check the values. 351 // Any value with: 352 // - IGNORE: - will not be compared but must exist 353 // - REGEX: - compare using a regular expression 354 // - anything else is an exact match 355 // Expected results can also have tokens that are replaced before comparing: 356 // - ${TEST_OUT_DIR} - the test output directory 357 foreach (string argname in expectedArgs) 358 { 359 var expectedValues = expected.GetArgumentValues(protofile, argname); 360 var actualValues = actual.GetArgumentValues(protofile, argname); 361 362 Assert.AreEqual(expectedValues.Count, actualValues.Count, 363 $"{protofile}: Wrong number of occurrences of argument '{argname}'"); 364 365 // Since generally the order of arguments on the commandline is important, 366 // it is fair to compare arguments with expected values one by one. 367 // Most arguments are only used at most once by the msbuild integration anyway. 368 for (int i = 0; i < expectedValues.Count; i++) 369 { 370 var expectedValue = ReplaceTokens(expectedValues[i]); 371 var actualValue = actualValues[i]; 372 373 if (expectedValue.StartsWith("IGNORE:")) 374 continue; 375 376 var regexPrefix = "REGEX:"; 377 if (expectedValue.StartsWith(regexPrefix)) 378 { 379 string pattern = expectedValue.Substring(regexPrefix.Length); 380 Assert.IsTrue(Regex.IsMatch(actualValue, pattern), 381 $"{protofile}: Expected value '{expectedValue}' for argument '{argname}'. Actual value: '{actualValue}'"); 382 } 383 else 384 { 385 Assert.AreEqual(expectedValue, actualValue, $"{protofile}: Wrong value for argument '{argname}'"); 386 } 387 } 388 } 389 } 390 } 391 ReplaceTokens(string original)392 private string ReplaceTokens(string original) 393 { 394 return original 395 .Replace("${TEST_OUT_DIR}", testOutDir); 396 } 397 398 /// <summary> 399 /// Helper class for formatting the string specifying the list of proto files and 400 /// the expected generated files for each proto file. 401 /// </summary> 402 public class ExpectedFilesBuilder 403 { 404 private readonly List<string> protoAndFiles = new List<string>(); 405 Add(string protoFile, params string[] files)406 public ExpectedFilesBuilder Add(string protoFile, params string[] files) 407 { 408 protoAndFiles.Add(protoFile + ":" + string.Join(";", files)); 409 return this; 410 } 411 ToString()412 public override string ToString() 413 { 414 return string.Join("|", protoAndFiles.ToArray()); 415 } 416 } 417 418 /// <summary> 419 /// Hold the JSON results 420 /// </summary> 421 public class Results 422 { 423 /// <summary> 424 /// JSON "Metadata" 425 /// </summary> 426 public Dictionary<string, string> Metadata { get; set; } 427 428 /// <summary> 429 /// JSON "Files" 430 /// </summary> 431 public Dictionary<string, Dictionary<string, List<string>>> Files { get; set; } 432 433 /// <summary> 434 /// Read a JSON file 435 /// </summary> 436 /// <param name="filepath"></param> 437 /// <returns></returns> Read(string filepath)438 public static Results Read(string filepath) 439 { 440 using (StreamReader file = File.OpenText(filepath)) 441 { 442 JsonSerializer serializer = new JsonSerializer(); 443 Results results = (Results)serializer.Deserialize(file, typeof(Results)); 444 return results; 445 } 446 } 447 448 /// <summary> 449 /// Get the proto file names from the JSON 450 /// </summary> 451 public SortedSet<string> ProtoFiles => new SortedSet<string>(Files.Keys); 452 453 /// <summary> 454 /// Get the protoc arguments for the associated proto file 455 /// </summary> 456 /// <param name="protofile"></param> 457 /// <returns></returns> GetArgumentNames(string protofile)458 public SortedSet<string> GetArgumentNames(string protofile) 459 { 460 Dictionary<string, List<string>> args; 461 if (Files.TryGetValue(protofile, out args)) 462 { 463 return new SortedSet<string>(args.Keys); 464 } 465 else 466 { 467 return new SortedSet<string>(); 468 } 469 } 470 471 /// <summary> 472 /// Get the values for the named argument for the proto file 473 /// </summary> 474 /// <param name="protofile">proto file</param> 475 /// <param name="name">argument</param> 476 /// <returns></returns> GetArgumentValues(string protofile, string name)477 public List<string> GetArgumentValues(string protofile, string name) 478 { 479 Dictionary<string, List<string>> args; 480 if (Files.TryGetValue(protofile, out args)) 481 { 482 List<string> values; 483 if (args.TryGetValue(name, out values)) 484 { 485 return new List<string>(values); 486 } 487 } 488 return new List<string>(); 489 } 490 } 491 } 492 493 494 } 495