xref: /aosp_15_r20/external/grpc-grpc/src/csharp/Grpc.Tools.Tests/MsBuildIntegrationTest.cs (revision cc02d7e222339f7a4f6ba5f422e6413f4bd931f2)
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