blob: 465a2a98ca8519969919212c1a2f4f46a220b8d8 [file] [log] [blame]
#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>();
}
}
}
}