diff --git a/src/Cli/dotnet/commands/dotnet-test/CliConstants.cs b/src/Cli/dotnet/commands/dotnet-test/CliConstants.cs new file mode 100644 index 000000000000..d6805e82b97d --- /dev/null +++ b/src/Cli/dotnet/commands/dotnet-test/CliConstants.cs @@ -0,0 +1,19 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.DotNet.Cli +{ + internal static class CliConstants + { + public const string HelpOptionKey = "--help"; + public const string MSBuildOptionKey = "--msbuild-params"; + public const string NoBuildOptionKey = "--no-build"; + public const string ServerOptionKey = "--server"; + public const string DotNetTestPipeOptionKey = "--dotnet-test-pipe"; + + public const string ServerOptionValue = "dotnettestcli"; + + public const string MSBuildExeName = "MSBuild.dll"; + + } +} diff --git a/src/Cli/dotnet/commands/dotnet-test/CustomEventArgs.cs b/src/Cli/dotnet/commands/dotnet-test/CustomEventArgs.cs new file mode 100644 index 000000000000..fa3b9abddfce --- /dev/null +++ b/src/Cli/dotnet/commands/dotnet-test/CustomEventArgs.cs @@ -0,0 +1,17 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.DotNet.Tools.Test; + +namespace Microsoft.DotNet.Cli +{ + internal class ErrorEventArgs : EventArgs + { + public string ErrorMessage { get; set; } + } + + internal class HelpEventArgs : EventArgs + { + public CommandLineOptionMessages CommandLineOptionMessages { get; set; } + } +} diff --git a/src/Cli/dotnet/commands/dotnet-test/IPC/IClient.cs b/src/Cli/dotnet/commands/dotnet-test/IPC/IClient.cs new file mode 100644 index 000000000000..5246008eca26 --- /dev/null +++ b/src/Cli/dotnet/commands/dotnet-test/IPC/IClient.cs @@ -0,0 +1,19 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.DotNet.Tools.Test; + +internal interface IClient : +#if NETCOREAPP +IAsyncDisposable, +#endif +IDisposable +{ + bool IsConnected { get; } + + Task ConnectAsync(CancellationToken cancellationToken); + + Task RequestReplyAsync(TRequest request, CancellationToken cancellationToken) + where TRequest : IRequest + where TResponse : IResponse; +} diff --git a/src/Cli/dotnet/commands/dotnet-test/IPC/INamedPipeBase.cs b/src/Cli/dotnet/commands/dotnet-test/IPC/INamedPipeBase.cs new file mode 100644 index 000000000000..5922b5e8b0b8 --- /dev/null +++ b/src/Cli/dotnet/commands/dotnet-test/IPC/INamedPipeBase.cs @@ -0,0 +1,9 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.DotNet.Tools.Test; + +internal interface INamedPipeBase +{ + void RegisterSerializer(INamedPipeSerializer namedPipeSerializer, Type type); +} diff --git a/src/Cli/dotnet/commands/dotnet-test/IPC/INamedPipeSerializer.cs b/src/Cli/dotnet/commands/dotnet-test/IPC/INamedPipeSerializer.cs new file mode 100644 index 000000000000..8e418132b77f --- /dev/null +++ b/src/Cli/dotnet/commands/dotnet-test/IPC/INamedPipeSerializer.cs @@ -0,0 +1,13 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.DotNet.Tools.Test; + +internal interface INamedPipeSerializer +{ + int Id { get; } + + void Serialize(object objectToSerialize, Stream stream); + + object Deserialize(Stream stream); +} diff --git a/src/Cli/dotnet/commands/dotnet-test/IPC/IRequest.cs b/src/Cli/dotnet/commands/dotnet-test/IPC/IRequest.cs new file mode 100644 index 000000000000..8463a5dfbd8c --- /dev/null +++ b/src/Cli/dotnet/commands/dotnet-test/IPC/IRequest.cs @@ -0,0 +1,8 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.DotNet.Tools.Test; + +internal interface IRequest +{ +} diff --git a/src/Cli/dotnet/commands/dotnet-test/IPC/IResponse.cs b/src/Cli/dotnet/commands/dotnet-test/IPC/IResponse.cs new file mode 100644 index 000000000000..f62b167332b6 --- /dev/null +++ b/src/Cli/dotnet/commands/dotnet-test/IPC/IResponse.cs @@ -0,0 +1,8 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.DotNet.Tools.Test; + +internal interface IResponse +{ +} diff --git a/src/Cli/dotnet/commands/dotnet-test/IPC/IServer.cs b/src/Cli/dotnet/commands/dotnet-test/IPC/IServer.cs new file mode 100644 index 000000000000..8327ae0ca3b9 --- /dev/null +++ b/src/Cli/dotnet/commands/dotnet-test/IPC/IServer.cs @@ -0,0 +1,15 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.DotNet.Tools.Test; + +internal interface IServer : INamedPipeBase, +#if NETCOREAPP +IAsyncDisposable, +#endif +IDisposable +{ + PipeNameDescription PipeName { get; } + + Task WaitConnectionAsync(CancellationToken cancellationToken); +} diff --git a/src/Cli/dotnet/commands/dotnet-test/IPC/Models/CommandLineOptionMessages.cs b/src/Cli/dotnet/commands/dotnet-test/IPC/Models/CommandLineOptionMessages.cs new file mode 100644 index 000000000000..f0fcae78be6c --- /dev/null +++ b/src/Cli/dotnet/commands/dotnet-test/IPC/Models/CommandLineOptionMessages.cs @@ -0,0 +1,9 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.DotNet.Tools.Test +{ + internal sealed record CommandLineOptionMessage(string Name, string Description, bool IsHidden, bool IsBuiltIn) : IRequest; + + internal sealed record CommandLineOptionMessages(string ModulePath, CommandLineOptionMessage[] CommandLineOptionMessageList) : IRequest; +} diff --git a/src/Cli/dotnet/commands/dotnet-test/IPC/Models/Module.cs b/src/Cli/dotnet/commands/dotnet-test/IPC/Models/Module.cs new file mode 100644 index 000000000000..7e9689b05dd1 --- /dev/null +++ b/src/Cli/dotnet/commands/dotnet-test/IPC/Models/Module.cs @@ -0,0 +1,6 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.DotNet.Tools.Test; + +internal sealed record class Module(string DLLPath, string ProjectPath) : IRequest; diff --git a/src/Cli/dotnet/commands/dotnet-test/IPC/Models/VoidResponse.cs b/src/Cli/dotnet/commands/dotnet-test/IPC/Models/VoidResponse.cs new file mode 100644 index 000000000000..1da501549e3f --- /dev/null +++ b/src/Cli/dotnet/commands/dotnet-test/IPC/Models/VoidResponse.cs @@ -0,0 +1,9 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.DotNet.Tools.Test; + +internal sealed class VoidResponse : IResponse +{ + public static readonly VoidResponse CachedInstance = new(); +} diff --git a/src/Cli/dotnet/commands/dotnet-test/IPC/NamedPipeBase.cs b/src/Cli/dotnet/commands/dotnet-test/IPC/NamedPipeBase.cs new file mode 100644 index 000000000000..8fef35d6297c --- /dev/null +++ b/src/Cli/dotnet/commands/dotnet-test/IPC/NamedPipeBase.cs @@ -0,0 +1,46 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#pragma warning disable IDE0240 // Remove redundant nullable directive +#nullable disable +#pragma warning restore IDE0240 // Remove redundant nullable directive + +using System.Globalization; + +namespace Microsoft.DotNet.Tools.Test; + +internal abstract class NamedPipeBase +{ + private readonly Dictionary _typeSerializer = []; + private readonly Dictionary _idSerializer = []; + + public void RegisterSerializer(INamedPipeSerializer namedPipeSerializer, Type type) + { + _typeSerializer.Add(type, namedPipeSerializer); + _idSerializer.Add(namedPipeSerializer.Id, namedPipeSerializer); + } + + protected INamedPipeSerializer GetSerializer(int id) + => _idSerializer.TryGetValue(id, out object serializer) + ? (INamedPipeSerializer)serializer + : throw new ArgumentException((string.Format( + CultureInfo.InvariantCulture, +#if dotnet + LocalizableStrings.NoSerializerRegisteredWithIdErrorMessage, +#else + "No serializer registered with ID '{0}'", +#endif + id))); + + protected INamedPipeSerializer GetSerializer(Type type) + => _typeSerializer.TryGetValue(type, out object serializer) + ? (INamedPipeSerializer)serializer + : throw new ArgumentException(string.Format( + CultureInfo.InvariantCulture, +#if dotnet + LocalizableStrings.NoSerializerRegisteredWithTypeErrorMessage, +#else + "No serializer registered with type '{0}'", +#endif + type)); +} diff --git a/src/Cli/dotnet/commands/dotnet-test/IPC/NamedPipeClient.cs b/src/Cli/dotnet/commands/dotnet-test/IPC/NamedPipeClient.cs new file mode 100644 index 000000000000..39c1bbc1ff16 --- /dev/null +++ b/src/Cli/dotnet/commands/dotnet-test/IPC/NamedPipeClient.cs @@ -0,0 +1,212 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Buffers; +using System.IO.Pipes; + +namespace Microsoft.DotNet.Tools.Test; + +internal sealed class NamedPipeClient : NamedPipeBase, IClient +{ + private readonly NamedPipeClientStream _namedPipeClientStream; + private readonly SemaphoreSlim _lock = new(1, 1); + + private readonly MemoryStream _serializationBuffer = new(); + private readonly MemoryStream _messageBuffer = new(); + private readonly byte[] _readBuffer = new byte[250000]; + private readonly string _pipeName; + + private bool _disposed; + + public NamedPipeClient(string name) + { + _namedPipeClientStream = new(".", name, PipeDirection.InOut); + _pipeName = name; + } + + public string PipeName => _pipeName; + + public bool IsConnected => _namedPipeClientStream.IsConnected; + + public async Task ConnectAsync(CancellationToken cancellationToken) + => await _namedPipeClientStream.ConnectAsync(cancellationToken); + + public async Task RequestReplyAsync(TRequest request, CancellationToken cancellationToken) + where TRequest : IRequest + where TResponse : IResponse + { + await _lock.WaitAsync(cancellationToken); + try + { + INamedPipeSerializer requestNamedPipeSerializer = GetSerializer(typeof(TRequest)); + + // Ask to serialize the body + _serializationBuffer.Position = 0; + requestNamedPipeSerializer.Serialize(request, _serializationBuffer); + + // Write the message size + _messageBuffer.Position = 0; + + // The length of the message is the size of the message plus one byte to store the serializer id + // Space for the message + int sizeOfTheWholeMessage = (int)_serializationBuffer.Position; + + // Space for the serializer id + sizeOfTheWholeMessage += sizeof(int); + + // Write the message size +#if NETCOREAPP + byte[] bytes = ArrayPool.Shared.Rent(sizeof(int)); + try + { + BitConverter.TryWriteBytes(bytes, sizeOfTheWholeMessage); + await _messageBuffer.WriteAsync(bytes.AsMemory(0, sizeof(int)), cancellationToken); + } + finally + { + ArrayPool.Shared.Return(bytes); + } +#else + await _messageBuffer.WriteAsync(BitConverter.GetBytes(sizeOfTheWholeMessage), 0, sizeof(int), cancellationToken); +#endif + + // Write the serializer id +#if NETCOREAPP + bytes = ArrayPool.Shared.Rent(sizeof(int)); + try + { + BitConverter.TryWriteBytes(bytes, requestNamedPipeSerializer.Id); + await _messageBuffer.WriteAsync(bytes.AsMemory(0, sizeof(int)), cancellationToken); + } + finally + { + ArrayPool.Shared.Return(bytes); + } +#else + await _messageBuffer.WriteAsync(BitConverter.GetBytes(requestNamedPipeSerializer.Id), 0, sizeof(int), cancellationToken); +#endif + + try + { + // Write the message +#if NETCOREAPP + await _messageBuffer.WriteAsync(_serializationBuffer.GetBuffer().AsMemory(0, (int)_serializationBuffer.Position), cancellationToken); +#else + await _messageBuffer.WriteAsync(_serializationBuffer.GetBuffer(), 0, (int)_serializationBuffer.Position, cancellationToken); +#endif + } + finally + { + // Reset the serialization buffer + _serializationBuffer.Position = 0; + } + + // Send the message + try + { +#if NETCOREAPP + await _namedPipeClientStream.WriteAsync(_messageBuffer.GetBuffer().AsMemory(0, (int)_messageBuffer.Position), cancellationToken); +#else + await _namedPipeClientStream.WriteAsync(_messageBuffer.GetBuffer(), 0, (int)_messageBuffer.Position, cancellationToken); +#endif + await _namedPipeClientStream.FlushAsync(cancellationToken); + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + _namedPipeClientStream.WaitForPipeDrain(); + } + } + finally + { + // Reset the buffers + _messageBuffer.Position = 0; + _serializationBuffer.Position = 0; + } + + // Read the response + int currentMessageSize = 0; + int missingBytesToReadOfWholeMessage = 0; + while (true) + { + int missingBytesToReadOfCurrentChunk = 0; + int currentReadIndex = 0; +#if NETCOREAPP + int currentReadBytes = await _namedPipeClientStream.ReadAsync(_readBuffer.AsMemory(currentReadIndex, _readBuffer.Length), cancellationToken); +#else + int currentReadBytes = await _namedPipeClientStream.ReadAsync(_readBuffer, currentReadIndex, _readBuffer.Length, cancellationToken); +#endif + // Reset the current chunk size + missingBytesToReadOfCurrentChunk = currentReadBytes; + + // If currentRequestSize is 0, we need to read the message size + if (currentMessageSize == 0) + { + // We need to read the message size, first 4 bytes + currentMessageSize = BitConverter.ToInt32(_readBuffer, 0); + missingBytesToReadOfCurrentChunk = currentReadBytes - sizeof(int); + missingBytesToReadOfWholeMessage = currentMessageSize; + currentReadIndex = sizeof(int); + } + + if (missingBytesToReadOfCurrentChunk > 0) + { + // We need to read the rest of the message +#if NETCOREAPP + await _messageBuffer.WriteAsync(_readBuffer.AsMemory(currentReadIndex, missingBytesToReadOfCurrentChunk), cancellationToken); +#else + await _messageBuffer.WriteAsync(_readBuffer, currentReadIndex, missingBytesToReadOfCurrentChunk, cancellationToken); +#endif + missingBytesToReadOfWholeMessage -= missingBytesToReadOfCurrentChunk; + } + + // If we have read all the message, we can deserialize it + if (missingBytesToReadOfWholeMessage == 0) + { + // Deserialize the message + _messageBuffer.Position = 0; + + // Get the serializer id + int serializerId = BitConverter.ToInt32(_messageBuffer.GetBuffer(), 0); + + // Get the serializer + _messageBuffer.Position += sizeof(int); // Skip the serializer id + INamedPipeSerializer responseNamedPipeSerializer = GetSerializer(serializerId); + + // Deserialize the message + try + { + return (TResponse)responseNamedPipeSerializer.Deserialize(_messageBuffer); + } + finally + { + // Reset the message buffer + _messageBuffer.Position = 0; + } + } + } + } + finally + { + _lock.Release(); + } + } + + public void Dispose() + { + if (!_disposed) + { + _namedPipeClientStream.Dispose(); + _disposed = true; + } + } + +#if NETCOREAPP + public async ValueTask DisposeAsync() + { + if (!_disposed) + { + await _namedPipeClientStream.DisposeAsync(); + _disposed = true; + } + } +#endif +} diff --git a/src/Cli/dotnet/commands/dotnet-test/IPC/NamedPipeServer.cs b/src/Cli/dotnet/commands/dotnet-test/IPC/NamedPipeServer.cs new file mode 100644 index 000000000000..35ca35b528a5 --- /dev/null +++ b/src/Cli/dotnet/commands/dotnet-test/IPC/NamedPipeServer.cs @@ -0,0 +1,305 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#pragma warning disable IDE0240 // Remove redundant nullable directive +#nullable disable +#pragma warning restore IDE0240 // Remove redundant nullable directive + +using System.Buffers; +using System.Globalization; +using System.IO.Pipes; + +namespace Microsoft.DotNet.Tools.Test; + +internal sealed class NamedPipeServer : NamedPipeBase, IServer +{ + private readonly Func> _callback; + private readonly NamedPipeServerStream _namedPipeServerStream; + private readonly CancellationToken _cancellationToken; + private readonly MemoryStream _serializationBuffer = new(); + private readonly MemoryStream _messageBuffer = new(); + private readonly byte[] _readBuffer = new byte[250000]; + private Task _loopTask; + private bool _disposed; + + public NamedPipeServer( + string name, + Func> callback, + CancellationToken cancellationToken) + : this(GetPipeName(name), callback, cancellationToken) + { + } + + public NamedPipeServer( + PipeNameDescription pipeNameDescription, + Func> callback, + CancellationToken cancellationToken) + : this(pipeNameDescription, callback, maxNumberOfServerInstances: 1, cancellationToken) + { + } + + public NamedPipeServer( + PipeNameDescription pipeNameDescription, + Func> callback, + int maxNumberOfServerInstances, + CancellationToken cancellationToken) + { + _namedPipeServerStream = new((PipeName = pipeNameDescription).Name, PipeDirection.InOut, maxNumberOfServerInstances); + _callback = callback; + _cancellationToken = cancellationToken; + } + + public PipeNameDescription PipeName { get; private set; } + + public bool WasConnected { get; private set; } + + public async Task WaitConnectionAsync(CancellationToken cancellationToken) + { + await _namedPipeServerStream.WaitForConnectionAsync(cancellationToken); + WasConnected = true; + _loopTask = Task.Run( + async () => + { + try + { + await InternalLoopAsync(_cancellationToken); + } + catch (OperationCanceledException ex) when (ex.CancellationToken == _cancellationToken) + { + // We are being cancelled, so we don't need to wait anymore + return; + } + catch (Exception ex) + { + Environment.FailFast($"[NamedPipeServer] Unhandled exception:{Environment.NewLine}{ex}", ex); + } + }, cancellationToken); + } + + /// + /// 4 bytes = message size + /// ------- Payload ------- + /// 4 bytes = serializer id + /// x bytes = object buffer. + /// + private async Task InternalLoopAsync(CancellationToken cancellationToken) + { + int currentMessageSize = 0; + int missingBytesToReadOfWholeMessage = 0; + while (!cancellationToken.IsCancellationRequested) + { + int missingBytesToReadOfCurrentChunk = 0; + int currentReadIndex = 0; +#if NET + int currentReadBytes = await _namedPipeServerStream.ReadAsync(_readBuffer.AsMemory(currentReadIndex, _readBuffer.Length), cancellationToken); +#else + int currentReadBytes = await _namedPipeServerStream.ReadAsync(_readBuffer, currentReadIndex, _readBuffer.Length, cancellationToken); +#endif + if (currentReadBytes == 0) + { + // The client has disconnected + return; + } + + // Reset the current chunk size + missingBytesToReadOfCurrentChunk = currentReadBytes; + + // If currentRequestSize is 0, we need to read the message size + if (currentMessageSize == 0) + { + // We need to read the message size, first 4 bytes + currentMessageSize = BitConverter.ToInt32(_readBuffer, 0); + missingBytesToReadOfCurrentChunk = currentReadBytes - sizeof(int); + missingBytesToReadOfWholeMessage = currentMessageSize; + currentReadIndex = sizeof(int); + } + + if (missingBytesToReadOfCurrentChunk > 0) + { + // We need to read the rest of the message +#if NET + await _messageBuffer.WriteAsync(_readBuffer.AsMemory(currentReadIndex, missingBytesToReadOfCurrentChunk), cancellationToken); +#else + await _messageBuffer.WriteAsync(_readBuffer, currentReadIndex, missingBytesToReadOfCurrentChunk, cancellationToken); +#endif + missingBytesToReadOfWholeMessage -= missingBytesToReadOfCurrentChunk; + } + + // If we have read all the message, we can deserialize it + if (missingBytesToReadOfWholeMessage == 0) + { + // Deserialize the message + _messageBuffer.Position = 0; + + // Get the serializer id + int serializerId = BitConverter.ToInt32(_messageBuffer.GetBuffer(), 0); + + // Get the serializer + INamedPipeSerializer requestNamedPipeSerializer = GetSerializer(serializerId); + + // Deserialize the message + _messageBuffer.Position += sizeof(int); // Skip the serializer id + var deserializedObject = (IRequest)requestNamedPipeSerializer.Deserialize(_messageBuffer); + + // Call the callback + IResponse response = await _callback(deserializedObject); + + // Write the message size + _messageBuffer.Position = 0; + + // Get the response serializer + INamedPipeSerializer responseNamedPipeSerializer = GetSerializer(response.GetType()); + + // Serialize the response + responseNamedPipeSerializer.Serialize(response, _serializationBuffer); + + // The length of the message is the size of the message plus one byte to store the serializer id + // Space for the message + int sizeOfTheWholeMessage = (int)_serializationBuffer.Position; + + // Space for the serializer id + sizeOfTheWholeMessage += sizeof(int); + + // Write the message size +#if NET + byte[] bytes = ArrayPool.Shared.Rent(sizeof(int)); + try + { + BitConverter.TryWriteBytes(bytes, sizeOfTheWholeMessage); + + await _messageBuffer.WriteAsync(bytes.AsMemory(0, sizeof(int)), cancellationToken); + } + finally + { + ArrayPool.Shared.Return(bytes); + } +#else + await _messageBuffer.WriteAsync(BitConverter.GetBytes(sizeOfTheWholeMessage), 0, sizeof(int), cancellationToken); +#endif + + // Write the serializer id +#if NET + bytes = ArrayPool.Shared.Rent(sizeof(int)); + try + { + BitConverter.TryWriteBytes(bytes, responseNamedPipeSerializer.Id); + + await _messageBuffer.WriteAsync(bytes.AsMemory(0, sizeof(int)), cancellationToken); + } + finally + { + ArrayPool.Shared.Return(bytes); + } +#else + await _messageBuffer.WriteAsync(BitConverter.GetBytes(responseNamedPipeSerializer.Id), 0, sizeof(int), cancellationToken); +#endif + + // Write the message +#if NET + await _messageBuffer.WriteAsync(_serializationBuffer.GetBuffer().AsMemory(0, (int)_serializationBuffer.Position), cancellationToken); +#else + await _messageBuffer.WriteAsync(_serializationBuffer.GetBuffer(), 0, (int)_serializationBuffer.Position, cancellationToken); +#endif + + // Send the message + try + { +#if NET + await _namedPipeServerStream.WriteAsync(_messageBuffer.GetBuffer().AsMemory(0, (int)_messageBuffer.Position), cancellationToken); +#else + await _namedPipeServerStream.WriteAsync(_messageBuffer.GetBuffer(), 0, (int)_messageBuffer.Position, cancellationToken); +#endif + await _namedPipeServerStream.FlushAsync(cancellationToken); + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + _namedPipeServerStream.WaitForPipeDrain(); + } + } + finally + { + // Reset the buffers + _messageBuffer.Position = 0; + _serializationBuffer.Position = 0; + } + + // Reset the control variables + currentMessageSize = 0; + missingBytesToReadOfWholeMessage = 0; + } + } + } + + public static PipeNameDescription GetPipeName(string name) + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return new PipeNameDescription($"testingplatform.pipe.{name.Replace('\\', '.')}", false); + } + + string directoryId = Path.Combine(Path.GetTempPath(), name); + Directory.CreateDirectory(directoryId); + return new PipeNameDescription( + !Directory.Exists(directoryId) + ? throw new DirectoryNotFoundException(string.Format( + CultureInfo.InvariantCulture, + $"Directory: {directoryId} doesn't exist.", + directoryId)) + : Path.Combine(directoryId, ".p"), true); + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + if (WasConnected) + { + // If the loop task is null at this point we have race condition, means that the task didn't start yet and we already dispose. + // This is unexpected and we throw an exception. + + // To close gracefully we need to ensure that the client closed the stream line 103. + if (!_loopTask.Wait(TimeSpan.FromSeconds(90))) + { + throw new InvalidOperationException("InternalLoopAsyncDidNotExitSuccessfullyErrorMessage"); + } + } + + _namedPipeServerStream.Dispose(); + PipeName.Dispose(); + + _disposed = true; + } + +#if NET + public async ValueTask DisposeAsync() + { + if (_disposed) + { + return; + } + + if (WasConnected) + { + // If the loop task is null at this point we have race condition, means that the task didn't start yet and we already dispose. + // This is unexpected and we throw an exception. + + try + { + // To close gracefully we need to ensure that the client closed the stream line 103. + await _loopTask.WaitAsync(TimeSpan.FromSeconds(90)); + } + catch (TimeoutException) + { + throw new InvalidOperationException("InternalLoopAsyncDidNotExitSuccessfullyErrorMessage"); + } + } + + _namedPipeServerStream.Dispose(); + PipeName.Dispose(); + + _disposed = true; + } +#endif +} diff --git a/src/Cli/dotnet/commands/dotnet-test/IPC/ObjectFieldIds.cs b/src/Cli/dotnet/commands/dotnet-test/IPC/ObjectFieldIds.cs new file mode 100644 index 000000000000..1e56428cdf67 --- /dev/null +++ b/src/Cli/dotnet/commands/dotnet-test/IPC/ObjectFieldIds.cs @@ -0,0 +1,23 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +// WARNING: Please note this file needs to be kept aligned with the one in the testfx repo. +// The protocol follows the concept of optional properties. +// The id is used to identify the property in the stream and it will be skipped if it's not recognized. +// We can add new properties with new ids, but we CANNOT change the existing ids (to support backwards compatibility). +namespace Microsoft.DotNet.Tools.Test +{ + internal static class CommandLineOptionMessagesFieldsId + { + internal const int ModulePath = 1; + internal const int CommandLineOptionMessageList = 2; + } + + internal static class CommandLineOptionMessageFieldsId + { + internal const int Name = 1; + internal const int Description = 2; + internal const int IsHidden = 3; + internal const int IsBuiltIn = 4; + } +} diff --git a/src/Cli/dotnet/commands/dotnet-test/IPC/PipeNameDescription.cs b/src/Cli/dotnet/commands/dotnet-test/IPC/PipeNameDescription.cs new file mode 100644 index 000000000000..c59ba93803a3 --- /dev/null +++ b/src/Cli/dotnet/commands/dotnet-test/IPC/PipeNameDescription.cs @@ -0,0 +1,41 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.DotNet.Tools.Test; + +internal sealed class PipeNameDescription(string name, bool isDirectory) : IDisposable +{ + private readonly bool _isDirectory = isDirectory; + private bool _disposed; + + public string Name { get; } = name; + + public void Dispose() => Dispose(true); + + public void Dispose(bool disposing) + { + if (_disposed) + { + return; + } + + if (disposing) + { + // TODO: dispose managed state (managed objects). + } + + if (_isDirectory) + { + try + { + Directory.Delete(Path.GetDirectoryName(Name)!, true); + } + catch (IOException) + { + // This folder is created inside the temp directory and will be cleaned up eventually by the OS + } + } + + _disposed = true; + } +} diff --git a/src/Cli/dotnet/commands/dotnet-test/IPC/Serializers/BaseSerializer.cs b/src/Cli/dotnet/commands/dotnet-test/IPC/Serializers/BaseSerializer.cs new file mode 100644 index 000000000000..652761727855 --- /dev/null +++ b/src/Cli/dotnet/commands/dotnet-test/IPC/Serializers/BaseSerializer.cs @@ -0,0 +1,256 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#if NETCOREAPP +#nullable enable +using System.Buffers; +#endif + +namespace Microsoft.DotNet.Tools.Test; + +internal abstract class BaseSerializer +{ +#if NETCOREAPP + protected static string ReadString(Stream stream) + { + Span len = stackalloc byte[sizeof(int)]; + stream.ReadExactly(len); + int stringLen = BitConverter.ToInt32(len); + byte[] bytes = ArrayPool.Shared.Rent(stringLen); + try + { + stream.ReadExactly(bytes, 0, stringLen); + return Encoding.UTF8.GetString(bytes, 0, stringLen); + } + finally + { + ArrayPool.Shared.Return(bytes); + } + } + + protected static void WriteString(Stream stream, string str) + { + int stringutf8TotalBytes = Encoding.UTF8.GetByteCount(str); + byte[] bytes = ArrayPool.Shared.Rent(stringutf8TotalBytes); + try + { + Span len = stackalloc byte[sizeof(int)]; + BitConverter.TryWriteBytes(len, stringutf8TotalBytes); + stream.Write(len); + + Encoding.UTF8.GetBytes(str, bytes); + stream.Write(bytes, 0, stringutf8TotalBytes); + } + finally + { + ArrayPool.Shared.Return(bytes); + } + } + + protected static void WriteStringSize(Stream stream, string str) + { + int stringutf8TotalBytes = Encoding.UTF8.GetByteCount(str); + Span len = stackalloc byte[sizeof(int)]; + if (BitConverter.TryWriteBytes(len, stringutf8TotalBytes)) + { + stream.Write(len); + } + } + + protected static void WriteSize(Stream stream) + where T : struct + { + int sizeInBytes = GetSize(); + Span len = stackalloc byte[sizeof(int)]; + + if (BitConverter.TryWriteBytes(len, sizeInBytes)) + { + stream.Write(len); + } + } + + protected static void WriteInt(Stream stream, int value) + { + Span bytes = stackalloc byte[sizeof(int)]; + BitConverter.TryWriteBytes(bytes, value); + + stream.Write(bytes); + } + + protected static void WriteLong(Stream stream, long value) + { + Span bytes = stackalloc byte[sizeof(long)]; + BitConverter.TryWriteBytes(bytes, value); + + stream.Write(bytes); + } + + protected static void WriteShort(Stream stream, ushort value) + { + Span bytes = stackalloc byte[sizeof(ushort)]; + BitConverter.TryWriteBytes(bytes, value); + + stream.Write(bytes); + } + + protected static void WriteBool(Stream stream, bool value) + { + Span bytes = stackalloc byte[sizeof(bool)]; + BitConverter.TryWriteBytes(bytes, value); + + stream.Write(bytes); + } + + protected static int ReadInt(Stream stream) + { + Span bytes = stackalloc byte[sizeof(int)]; + stream.ReadExactly(bytes); + return BitConverter.ToInt32(bytes); + } + + protected static long ReadLong(Stream stream) + { + Span bytes = stackalloc byte[sizeof(long)]; + stream.ReadExactly(bytes); + return BitConverter.ToInt64(bytes); + } + + protected static ushort ReadShort(Stream stream) + { + Span bytes = stackalloc byte[sizeof(ushort)]; + stream.ReadExactly(bytes); + return BitConverter.ToUInt16(bytes); + } + + protected static bool ReadBool(Stream stream) + { + Span bytes = stackalloc byte[sizeof(bool)]; + stream.ReadExactly(bytes); + return BitConverter.ToBoolean(bytes); + } + +#else + protected static string ReadString(Stream stream) + { + byte[] len = new byte[sizeof(int)]; + stream.Read(len, 0, len.Length); + int length = BitConverter.ToInt32(len, 0); + byte[] bytes = new byte[length]; + stream.Read(bytes, 0, bytes.Length); + return Encoding.UTF8.GetString(bytes); + } + + protected static void WriteString(Stream stream, string str) + { + byte[] bytes = Encoding.UTF8.GetBytes(str); + byte[] len = BitConverter.GetBytes(bytes.Length); + stream.Write(len, 0, len.Length); + stream.Write(bytes, 0, bytes.Length); + } + + protected static void WriteStringSize(Stream stream, string str) + { + byte[] bytes = Encoding.UTF8.GetBytes(str); + byte[] len = BitConverter.GetBytes(bytes.Length); + stream.Write(len, 0, len.Length); + } + + protected static void WriteSize(Stream stream) + where T : struct + { + int sizeInBytes = GetSize(); + byte[] len = BitConverter.GetBytes(sizeInBytes); + stream.Write(len, 0, sizeInBytes); + } + + protected static void WriteInt(Stream stream, int value) + { + byte[] bytes = BitConverter.GetBytes(value); + stream.Write(bytes, 0, bytes.Length); + } + + protected static int ReadInt(Stream stream) + { + byte[] bytes = new byte[sizeof(int)]; + stream.Read(bytes, 0, bytes.Length); + return BitConverter.ToInt32(bytes, 0); + } + + protected static void WriteLong(Stream stream, long value) + { + byte[] bytes = BitConverter.GetBytes(value); + stream.Write(bytes, 0, bytes.Length); + } + + protected static void WriteShort(Stream stream, ushort value) + { + byte[] bytes = BitConverter.GetBytes(value); + stream.Write(bytes, 0, bytes.Length); + } + + protected static long ReadLong(Stream stream) + { + byte[] bytes = new byte[sizeof(long)]; + stream.Read(bytes, 0, bytes.Length); + return BitConverter.ToInt64(bytes, 0); + } + + protected static ushort ReadShort(Stream stream) + { + byte[] bytes = new byte[sizeof(ushort)]; + stream.Read(bytes, 0, bytes.Length); + return BitConverter.ToUInt16(bytes, 0); + } + + protected static void WriteBool(Stream stream, bool value) + { + byte[] bytes = BitConverter.GetBytes(value); + stream.Write(bytes, 0, bytes.Length); + } + + protected static bool ReadBool(Stream stream) + { + byte[] bytes = new byte[sizeof(bool)]; + stream.Read(bytes, 0, bytes.Length); + return BitConverter.ToBoolean(bytes, 0); + } +#endif + + protected static void WriteField(Stream stream, ushort id, string? value) + { + if (value is null) + { + return; + } + + WriteShort(stream, id); + WriteStringSize(stream, value); + WriteString(stream, value); + } + + protected static void WriteField(Stream stream, ushort id, bool value) + { + WriteShort(stream, id); + WriteSize(stream); + WriteBool(stream, value); + } + + protected static void SetPosition(Stream stream, long position) => stream.Position = position; + + protected static void WriteAtPosition(Stream stream, int value, long position) + { + long currentPosition = stream.Position; + SetPosition(stream, position); + WriteInt(stream, value); + SetPosition(stream, currentPosition); + } + + private static int GetSize() => typeof(T) switch + { + Type type when type == typeof(int) => sizeof(int), + Type type when type == typeof(long) => sizeof(long), + Type type when type == typeof(short) => sizeof(short), + Type type when type == typeof(bool) => sizeof(bool), + _ => 0, + }; +} diff --git a/src/Cli/dotnet/commands/dotnet-test/IPC/Serializers/CommandLineOptionMessagesSerializer.cs b/src/Cli/dotnet/commands/dotnet-test/IPC/Serializers/CommandLineOptionMessagesSerializer.cs new file mode 100644 index 000000000000..41d4c97569c3 --- /dev/null +++ b/src/Cli/dotnet/commands/dotnet-test/IPC/Serializers/CommandLineOptionMessagesSerializer.cs @@ -0,0 +1,177 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#if NETCOREAPP +#nullable enable +#endif + +using System.Diagnostics; + +namespace Microsoft.DotNet.Tools.Test +{ + /* + |---FieldCount---| 2 bytes + + |---ModuleName Id---| 1 (2 bytes) + |---ModuleName Size---| (4 bytes) + |---ModuleName Value---| (n bytes) + + |---CommandLineOptionMessageList Id---| 2 (2 bytes) + |---CommandLineOptionMessageList Size---| (4 bytes) + |---CommandLineOptionMessageList Value---| (n bytes) + |---CommandLineOptionMessageList Length---| (4 bytes) + + |---CommandLineOptionMessageList[0] FieldCount---| 2 bytes + + |---CommandLineOptionMessageList[0] Name Id---| 1 (2 bytes) + |---CommandLineOptionMessageList[0] Name Size---| (4 bytes) + |---CommandLineOptionMessageList[0] Name Value---| (n bytes) + + |---CommandLineOptionMessageList[1] Description Id---| 2 (2 bytes) + |---CommandLineOptionMessageList[1] Description Size---| (4 bytes) + |---CommandLineOptionMessageList[1] Description Value---| (n bytes) + + |---CommandLineOptionMessageList[3] IsHidden Id---| 4 (2 bytes) + |---CommandLineOptionMessageList[3] IsHidden Size---| (4 bytes) + |---CommandLineOptionMessageList[3] IsHidden Value---| (1 byte) + + |---CommandLineOptionMessageList[4] IsBuiltIn Id---| 5 (2 bytes) + |---CommandLineOptionMessageList[4] IsBuiltIn Size---| (4 bytes) + |---CommandLineOptionMessageList[4] IsBuiltIn Value---| (1 byte) + */ + + internal sealed class CommandLineOptionMessagesSerializer : BaseSerializer, INamedPipeSerializer + { + public int Id => 3; + + public object Deserialize(Stream stream) + { + string moduleName = string.Empty; + List? commandLineOptionMessages = null; + + ushort fieldCount = ReadShort(stream); + + for (int i = 0; i < fieldCount; i++) + { + int fieldId = ReadShort(stream); + int fieldSize = ReadInt(stream); + + switch (fieldId) + { + case CommandLineOptionMessagesFieldsId.ModulePath: + moduleName = ReadString(stream); + break; + + case CommandLineOptionMessagesFieldsId.CommandLineOptionMessageList: + commandLineOptionMessages = ReadCommandLineOptionMessagesPayload(stream); + break; + + default: + // If we don't recognize the field id, skip the payload corresponding to that field + SetPosition(stream, stream.Position + fieldSize); + break; + } + } + + return new CommandLineOptionMessages(moduleName, commandLineOptionMessages is null ? [] : [.. commandLineOptionMessages]); + } + + private static List ReadCommandLineOptionMessagesPayload(Stream stream) + { + List commandLineOptionMessages = []; + + int length = ReadInt(stream); + for (int i = 0; i < length; i++) + { + string name = string.Empty, description = string.Empty, arity = string.Empty; + bool isHidden = false, isBuiltIn = false; + + int fieldCount = ReadShort(stream); + + for (int j = 0; j < fieldCount; j++) + { + int fieldId = ReadShort(stream); + int fieldSize = ReadInt(stream); + + switch (fieldId) + { + case CommandLineOptionMessageFieldsId.Name: + name = ReadString(stream); + break; + + case CommandLineOptionMessageFieldsId.Description: + description = ReadString(stream); + break; + + case CommandLineOptionMessageFieldsId.IsHidden: + isHidden = ReadBool(stream); + break; + + case CommandLineOptionMessageFieldsId.IsBuiltIn: + isBuiltIn = ReadBool(stream); + break; + + default: + SetPosition(stream, stream.Position + fieldSize); + break; + } + } + + commandLineOptionMessages.Add(new CommandLineOptionMessage(name, description, isHidden, isBuiltIn)); + } + + return commandLineOptionMessages; + } + + public void Serialize(object objectToSerialize, Stream stream) + { + Debug.Assert(stream.CanSeek, "We expect a seekable stream."); + + var commandLineOptionMessages = (CommandLineOptionMessages)objectToSerialize; + + WriteShort(stream, GetFieldCount(commandLineOptionMessages)); + + WriteField(stream, CommandLineOptionMessagesFieldsId.ModulePath, commandLineOptionMessages.ModulePath); + WriteCommandLineOptionMessagesPayload(stream, commandLineOptionMessages.CommandLineOptionMessageList); + } + + private static void WriteCommandLineOptionMessagesPayload(Stream stream, CommandLineOptionMessage[] commandLineOptionMessageList) + { + if (IsNull(commandLineOptionMessageList)) + { + return; + } + + WriteShort(stream, CommandLineOptionMessagesFieldsId.CommandLineOptionMessageList); + + // We will reserve an int (4 bytes) + // so that we fill the size later, once we write the payload + WriteInt(stream, 0); + + long before = stream.Position; + WriteInt(stream, commandLineOptionMessageList.Length); + foreach (CommandLineOptionMessage commandLineOptionMessage in commandLineOptionMessageList) + { + WriteShort(stream, GetFieldCount(commandLineOptionMessage)); + + WriteField(stream, CommandLineOptionMessageFieldsId.Name, commandLineOptionMessage.Name); + WriteField(stream, CommandLineOptionMessageFieldsId.Description, commandLineOptionMessage.Description); + WriteField(stream, CommandLineOptionMessageFieldsId.IsHidden, commandLineOptionMessage.IsHidden); + WriteField(stream, CommandLineOptionMessageFieldsId.IsBuiltIn, commandLineOptionMessage.IsBuiltIn); + } + + // NOTE: We are able to seek only if we are using a MemoryStream + // thus, the seek operation is fast as we are only changing the value of a property + WriteAtPosition(stream, (int)(stream.Position - before), before - sizeof(int)); + } + + private static ushort GetFieldCount(CommandLineOptionMessages commandLineOptionMessages) => (ushort)((string.IsNullOrEmpty(commandLineOptionMessages.ModulePath) ? 0 : 1) + + (commandLineOptionMessages is null ? 0 : 1)); + + private static ushort GetFieldCount(CommandLineOptionMessage commandLineOptionMessage) => (ushort)((string.IsNullOrEmpty(commandLineOptionMessage.Name) ? 0 : 1) + + (string.IsNullOrEmpty(commandLineOptionMessage.Description) ? 0 : 1) + + 2); + + private static bool IsNull(T[] items) => items is null || items.Length == 0; + } +} diff --git a/src/Cli/dotnet/commands/dotnet-test/IPC/Serializers/ModuleSerializer.cs b/src/Cli/dotnet/commands/dotnet-test/IPC/Serializers/ModuleSerializer.cs new file mode 100644 index 000000000000..a086537777e6 --- /dev/null +++ b/src/Cli/dotnet/commands/dotnet-test/IPC/Serializers/ModuleSerializer.cs @@ -0,0 +1,23 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.DotNet.Tools.Test +{ + internal sealed class ModuleSerializer : BaseSerializer, INamedPipeSerializer + { + public int Id => 4; + + public object Deserialize(Stream stream) + { + string modulePath = ReadString(stream); + string projectPath = ReadString(stream); + return new Module(modulePath.Trim(), projectPath.Trim()); + } + + public void Serialize(object objectToSerialize, Stream stream) + { + WriteString(stream, ((Module)objectToSerialize).DLLPath); + WriteString(stream, ((Module)objectToSerialize).ProjectPath); + } + } +} diff --git a/src/Cli/dotnet/commands/dotnet-test/IPC/Serializers/RegisterSerializers.cs b/src/Cli/dotnet/commands/dotnet-test/IPC/Serializers/RegisterSerializers.cs new file mode 100644 index 000000000000..b38aca92969c --- /dev/null +++ b/src/Cli/dotnet/commands/dotnet-test/IPC/Serializers/RegisterSerializers.cs @@ -0,0 +1,23 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.DotNet.Tools.Test; + +/* + * NOTE: We have the following ids used for those serializers + * DO NOT change the IDs of the existing serializers + * VoidResponseSerializer: 0 + * TestHostProcessExitRequestSerializer: 1 + * TestHostProcessPIDRequestSerializer: 2 + * CommandLineOptionMessagesSerializer: 3 + * ModuleSerializer: 4 +*/ +internal static class RegisterSerializers +{ + public static void RegisterAllSerializers(this NamedPipeBase namedPipeBase) + { + namedPipeBase.RegisterSerializer(new VoidResponseSerializer(), typeof(VoidResponse)); + namedPipeBase.RegisterSerializer(new ModuleSerializer(), typeof(Module)); + namedPipeBase.RegisterSerializer(new CommandLineOptionMessagesSerializer(), typeof(CommandLineOptionMessages)); + } +} diff --git a/src/Cli/dotnet/commands/dotnet-test/IPC/Serializers/VoidResponseSerializer.cs b/src/Cli/dotnet/commands/dotnet-test/IPC/Serializers/VoidResponseSerializer.cs new file mode 100644 index 000000000000..9a27dd87cdcb --- /dev/null +++ b/src/Cli/dotnet/commands/dotnet-test/IPC/Serializers/VoidResponseSerializer.cs @@ -0,0 +1,16 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.DotNet.Tools.Test; + +internal sealed class VoidResponseSerializer : INamedPipeSerializer +{ + public int Id => 0; + + public object Deserialize(Stream _) + => new VoidResponse(); + + public void Serialize(object _, Stream __) + { + } +} diff --git a/src/Cli/dotnet/commands/dotnet-test/LocalizableStrings.resx b/src/Cli/dotnet/commands/dotnet-test/LocalizableStrings.resx index 9e8126310519..05e0d04ce015 100644 --- a/src/Cli/dotnet/commands/dotnet-test/LocalizableStrings.resx +++ b/src/Cli/dotnet/commands/dotnet-test/LocalizableStrings.resx @@ -285,4 +285,10 @@ Examples: NAME="VALUE" + + No serializer registered with ID '{0}' + + + No serializer registered with type '{0}' + diff --git a/src/Cli/dotnet/commands/dotnet-test/TestApplication.cs b/src/Cli/dotnet/commands/dotnet-test/TestApplication.cs new file mode 100644 index 000000000000..072057af0749 --- /dev/null +++ b/src/Cli/dotnet/commands/dotnet-test/TestApplication.cs @@ -0,0 +1,70 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using Microsoft.DotNet.Tools.Test; + +namespace Microsoft.DotNet.Cli +{ + internal class TestApplication + { + private readonly string _modulePath; + private readonly string _pipeName; + private readonly string[] _args; + + public event EventHandler HelpRequested; + public event EventHandler ErrorReceived; + + public string ModuleName => _modulePath; + + public TestApplication(string moduleName, string pipeName, string[] args) + { + _modulePath = moduleName; + _pipeName = pipeName; + _args = args; + } + + public async Task RunAsync() + { + if (!File.Exists(_modulePath)) + { + ErrorReceived.Invoke(this, new ErrorEventArgs { ErrorMessage = $"Test module '{_modulePath}' not found. Build the test application before or run 'dotnet test'." }); + return; + } + + bool isDll = _modulePath.EndsWith(".dll"); + ProcessStartInfo processStartInfo = new() + { + FileName = isDll ? + Environment.ProcessPath : + _modulePath, + Arguments = BuildArgs(isDll) + }; + + VSTestTrace.SafeWriteTrace(() => $"Updated args: {processStartInfo.Arguments}"); + + await Process.Start(processStartInfo).WaitForExitAsync(); + } + + private string BuildArgs(bool isDll) + { + StringBuilder builder = new(); + + if (isDll) + builder.Append($"exec {_modulePath} "); + + builder.Append(_args.Length != 0 + ? _args.Aggregate((a, b) => $"{a} {b}") + : string.Empty); + + builder.Append($" {CliConstants.ServerOptionKey} {CliConstants.ServerOptionValue} {CliConstants.DotNetTestPipeOptionKey} {_pipeName}"); + + return builder.ToString(); + } + + public void OnCommandLineOptionMessages(CommandLineOptionMessages commandLineOptionMessages) + { + HelpRequested?.Invoke(this, new HelpEventArgs { CommandLineOptionMessages = commandLineOptionMessages }); + } + } +} diff --git a/src/Cli/dotnet/commands/dotnet-test/TestCommandParser.cs b/src/Cli/dotnet/commands/dotnet-test/TestCommandParser.cs index c31983f40ac1..12e3f1ea0f29 100644 --- a/src/Cli/dotnet/commands/dotnet-test/TestCommandParser.cs +++ b/src/Cli/dotnet/commands/dotnet-test/TestCommandParser.cs @@ -157,8 +157,44 @@ public static CliCommand GetCommand() return Command; } + private static bool IsTestingPlatformEnabled() + { + var testingPlatformEnabledEnvironmentVariable = Environment.GetEnvironmentVariable("DOTNET_CLI_TESTINGPLATFORM_ENABLE"); + var isTestingPlatformEnabled = testingPlatformEnabledEnvironmentVariable == "1" || string.Equals(testingPlatformEnabledEnvironmentVariable, "true", StringComparison.OrdinalIgnoreCase); + return isTestingPlatformEnabled; + } + private static CliCommand ConstructCommand() { +#if RELEASE + return GetVSTestCliCommand(); +#else + bool isTestingPlatformEnabled = IsTestingPlatformEnabled(); + string testingSdkName = isTestingPlatformEnabled ? "testingplatform" : "vstest"; + + if (isTestingPlatformEnabled) + { + return GetTestingPlatformCliCommand(); + } + else + { + return GetVSTestCliCommand(); + } + + throw new InvalidOperationException($"Testing sdk not supported: {testingSdkName}"); +#endif + } + + private static CliCommand GetTestingPlatformCliCommand() + { + var command = new TestingPlatformCommand("test"); + command.SetAction((parseResult) => command.Run(parseResult)); + + return command; + } + + private static CliCommand GetVSTestCliCommand() + { DocumentedCommand command = new("test", DocsLink, LocalizableStrings.AppFullName) { TreatUnmatchedTokensAsErrors = false @@ -196,7 +232,6 @@ private static CliCommand ConstructCommand() command.Options.Add(CommonOptions.ArchitectureOption); command.Options.Add(CommonOptions.OperatingSystemOption); command.Options.Add(CommonOptions.DisableBuildServersOption); - command.SetAction(TestCommand.Run); return command; diff --git a/src/Cli/dotnet/commands/dotnet-test/TestingPlatformCommand.Help.cs b/src/Cli/dotnet/commands/dotnet-test/TestingPlatformCommand.Help.cs new file mode 100644 index 000000000000..c163f79fc286 --- /dev/null +++ b/src/Cli/dotnet/commands/dotnet-test/TestingPlatformCommand.Help.cs @@ -0,0 +1,167 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Buffers; +using System.CommandLine.Help; +using Microsoft.DotNet.Tools.Test; + +namespace Microsoft.DotNet.Cli +{ + internal partial class TestingPlatformCommand + { + public IEnumerable> CustomHelpLayout() + { + yield return (context) => + { + Console.WriteLine("Waiting for options and extensions..."); + + Run(context.ParseResult); + + if (_commandLineOptionNameToModuleNames.IsEmpty) + { + return; + } + + Dictionary> allOptions = GetAllOptions(); + WriteOptionsToConsole(allOptions); + + Console.ForegroundColor = ConsoleColor.Yellow; + + Dictionary> moduleToMissingOptions = GetModulesToMissingOptions(allOptions); + WriteModulesToMissingOptionsToConsole(moduleToMissingOptions); + + Console.WriteLine(); + Console.ForegroundColor = ConsoleColor.White; + }; + } + + private void OnHelpRequested(object sender, HelpEventArgs args) + { + CommandLineOptionMessages commandLineOptionMessages = args.CommandLineOptionMessages; + string moduleName = commandLineOptionMessages.ModulePath; + + List builtInOptions = []; + List nonBuiltInOptions = []; + + foreach (CommandLineOptionMessage commandLineOptionMessage in commandLineOptionMessages.CommandLineOptionMessageList) + { + if (commandLineOptionMessage.IsHidden) continue; + + if (commandLineOptionMessage.IsBuiltIn) + { + builtInOptions.Add(commandLineOptionMessage.Name); + } + else + { + nonBuiltInOptions.Add(commandLineOptionMessage.Name); + } + + _commandLineOptionNameToModuleNames.AddOrUpdate( + commandLineOptionMessage.Name, + commandLineOptionMessage, + (optionName, value) => (value)); + } + + _moduleNamesToCommandLineOptions.AddOrUpdate(true, + [(moduleName, builtInOptions.ToArray())], + (isBuiltIn, value) => [.. value, (moduleName, builtInOptions.ToArray())]); + + _moduleNamesToCommandLineOptions.AddOrUpdate(false, + [(moduleName, nonBuiltInOptions.ToArray())], + (isBuiltIn, value) => [.. value, (moduleName, nonBuiltInOptions.ToArray())]); + } + + private Dictionary> GetAllOptions() + { + Dictionary> builtInToOptions = []; + + foreach (KeyValuePair option in _commandLineOptionNameToModuleNames) + { + if (!builtInToOptions.TryGetValue(option.Value.IsBuiltIn, out List value)) + { + builtInToOptions.Add(option.Value.IsBuiltIn, [option.Value]); + } + else + { + value.Add(option.Value); + } + } + return builtInToOptions; + } + + private Dictionary> GetModulesToMissingOptions(Dictionary> options) + { + IEnumerable builtInOptions = options.TryGetValue(true, out List builtIn) ? builtIn.Select(option => option.Name) : []; + IEnumerable nonBuiltInOptions = options.TryGetValue(false, out List nonBuiltIn) ? nonBuiltIn.Select(option => option.Name) : []; + + Dictionary> modulesWithMissingOptions = []; + + foreach (KeyValuePair> modulesToOptions in _moduleNamesToCommandLineOptions) + { + foreach ((string module, string[] relatedOptions) in modulesToOptions.Value) + { + IEnumerable allOptions = modulesToOptions.Key ? builtInOptions : nonBuiltInOptions; + string[] missingOptions = allOptions.Except(relatedOptions).ToArray(); + + if (missingOptions.Length == 0) + continue; + + if (modulesWithMissingOptions.TryGetValue(modulesToOptions.Key, out List<(string, string[])> value)) + { + value.Add((module, missingOptions)); + } + else + { + modulesWithMissingOptions.Add(modulesToOptions.Key, [(module, missingOptions)]); + } + } + } + return modulesWithMissingOptions; + } + + private void WriteOptionsToConsole(Dictionary> options) + { + int maxOptionNameLength = _commandLineOptionNameToModuleNames.Keys.ToArray().Max(option => option.Length); + + foreach (KeyValuePair> optionGroup in options) + { + Console.WriteLine(); + Console.WriteLine(optionGroup.Key ? "Options:" : "Extension options:"); + + foreach (CommandLineOptionMessage option in optionGroup.Value) + { + Console.WriteLine($"{new string(' ', 2)}--{option.Name}{new string(' ', maxOptionNameLength - option.Name.Length)} {option.Description}"); + } + } + } + + private static void WriteModulesToMissingOptionsToConsole(Dictionary> modulesWithMissingOptions) + { + foreach (KeyValuePair> groupedModules in modulesWithMissingOptions) + { + Console.WriteLine(); + Console.WriteLine(groupedModules.Key ? "Unavailable options:" : "Unavailable extension options:"); + + foreach ((string module, string[] missingOptions) in groupedModules.Value) + { + if (module.Length == 0) + { + continue; + } + + StringBuilder line = new(); + for (int i = 0; i < missingOptions.Length; i++) + { + if (i == missingOptions.Length - 1) + line.Append($"--{missingOptions[i]}"); + else + line.Append($"--{missingOptions[i]}\n"); + } + + string verb = missingOptions.Length == 1 ? "" : "(s)"; + Console.WriteLine($"{module} is missing the option{verb} below\n{line}\n"); + } + } + } + } +} diff --git a/src/Cli/dotnet/commands/dotnet-test/TestingPlatformCommand.cs b/src/Cli/dotnet/commands/dotnet-test/TestingPlatformCommand.cs new file mode 100644 index 000000000000..4b885e5c6d6f --- /dev/null +++ b/src/Cli/dotnet/commands/dotnet-test/TestingPlatformCommand.cs @@ -0,0 +1,156 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Concurrent; +using System.CommandLine; +using System.IO.Pipes; +using Microsoft.DotNet.Cli.Utils; +using Microsoft.DotNet.Tools.Test; +using Microsoft.TemplateEngine.Cli.Commands; + +namespace Microsoft.DotNet.Cli +{ + internal partial class TestingPlatformCommand : CliCommand, ICustomHelp + { + private readonly List _namedPipeServers = new(); + private readonly List _taskModuleName = []; + private readonly ConcurrentBag _testsRun = []; + private readonly ConcurrentDictionary _commandLineOptionNameToModuleNames = []; + private readonly ConcurrentDictionary> _moduleNamesToCommandLineOptions = []; + private readonly ConcurrentDictionary _testApplications = []; + private readonly PipeNameDescription _pipeNameDescription = NamedPipeServer.GetPipeName(Guid.NewGuid().ToString("N")); + private readonly CancellationTokenSource _cancellationToken = new(); + + private Task _namedPipeConnectionLoop; + private string[] _args; + + public TestingPlatformCommand(string name, string description = null) : base(name, description) + { + TreatUnmatchedTokensAsErrors = false; + } + + public int Run(ParseResult parseResult) + { + _args = parseResult.GetArguments().Except(parseResult.UnmatchedTokens).ToArray(); + + VSTestTrace.SafeWriteTrace(() => $"Wait for connection(s) on pipe = {_pipeNameDescription.Name}"); + _namedPipeConnectionLoop = Task.Run(async () => await WaitConnectionAsync(_cancellationToken.Token)); + + bool containsNoBuild = parseResult.UnmatchedTokens.Any(token => token == CliConstants.NoBuildOptionKey); + + ForwardingAppImplementation msBuildForwardingApp = new( + GetMSBuildExePath(), + [$"-t:{(containsNoBuild ? string.Empty : "Build;")}_GetTestsProject", + $"-p:GetTestsProjectPipeName={_pipeNameDescription.Name}", + "-verbosity:q"]); + int getTestsProjectResult = msBuildForwardingApp.Execute(); + + // Above line will block till we have all connections and all GetTestsProject msbuild task complete. + Task.WaitAll([.. _taskModuleName]); + Task.WaitAll([.. _testsRun]); + _cancellationToken.Cancel(); + _namedPipeConnectionLoop.Wait(); + + return 0; + } + + private async Task WaitConnectionAsync(CancellationToken token) + { + try + { + while (true) + { + NamedPipeServer namedPipeServer = new(_pipeNameDescription, OnRequest, NamedPipeServerStream.MaxAllowedServerInstances, token); + namedPipeServer.RegisterAllSerializers(); + + await namedPipeServer.WaitConnectionAsync(token); + + _namedPipeServers.Add(namedPipeServer); + } + } + catch (OperationCanceledException ex) when (ex.CancellationToken == token) + { + // We are exiting + } + catch (Exception ex) + { + VSTestTrace.SafeWriteTrace(() => ex.ToString()); + throw; + } + } + + private Task OnRequest(IRequest request) + { + if (TryGetModuleName(request, out string moduleName)) + { + TestApplication testApplication = GenerateTestApplication(moduleName); + _testApplications[moduleName] = testApplication; + + _testsRun.Add(Task.Run(async () => await testApplication.RunAsync())); + + return Task.FromResult((IResponse)VoidResponse.CachedInstance); + } + + if (TryGetHelpResponse(request, out CommandLineOptionMessages commandLineOptionMessages)) + { + var testApplication = _testApplications[commandLineOptionMessages.ModulePath]; + testApplication?.OnCommandLineOptionMessages(commandLineOptionMessages); + + return Task.FromResult((IResponse)VoidResponse.CachedInstance); + } + + throw new NotSupportedException($"Request '{request.GetType()}' is unsupported."); + } + + private static bool TryGetModuleName(IRequest request, out string modulePath) + { + if (request is Module module) + { + modulePath = module.DLLPath; + return true; + } + + modulePath = null; + return false; + } + + private static bool TryGetHelpResponse(IRequest request, out CommandLineOptionMessages commandLineOptionMessages) + { + if (request is CommandLineOptionMessages result) + { + commandLineOptionMessages = result; + return true; + } + + commandLineOptionMessages = null; + return false; + } + + private TestApplication GenerateTestApplication(string moduleName) + { + var testApplication = new TestApplication(moduleName, _pipeNameDescription.Name, _args); + + if (ContainsHelpOption(_args)) + { + testApplication.HelpRequested += OnHelpRequested; + } + testApplication.ErrorReceived += OnErrorReceived; + + return testApplication; + } + + private void OnErrorReceived(object sender, ErrorEventArgs args) + { + VSTestTrace.SafeWriteTrace(() => args.ErrorMessage); + } + + private static bool ContainsHelpOption(IEnumerable args) => args.Contains(CliConstants.HelpOptionKey) || args.Contains(CliConstants.HelpOptionKey.Substring(0, 2)); + + private static string GetMSBuildExePath() + { + return Path.Combine( + AppContext.BaseDirectory, + CliConstants.MSBuildExeName); + } + } +} diff --git a/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.cs.xlf b/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.cs.xlf index 8d36d8e1b0e0..7480527fc429 100644 --- a/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.cs.xlf +++ b/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.cs.xlf @@ -225,6 +225,16 @@ Pokud zadaný adresář neexistuje, bude vytvořen. Následující argumenty se ignorovaly: {0} + + No serializer registered with ID '{0}' + No serializer registered with ID '{0}' + + + + No serializer registered with type '{0}' + No serializer registered with type '{0}' + + diff --git a/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.de.xlf b/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.de.xlf index ab5a34ffcaa0..ee32bb0b1cf8 100644 --- a/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.de.xlf +++ b/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.de.xlf @@ -225,6 +225,16 @@ Das angegebene Verzeichnis wird erstellt, wenn es nicht vorhanden ist. Die folgenden Argumente wurden ignoriert: {0} + + No serializer registered with ID '{0}' + No serializer registered with ID '{0}' + + + + No serializer registered with type '{0}' + No serializer registered with type '{0}' + + diff --git a/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.es.xlf b/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.es.xlf index 74246e2d9a0d..87b879459a3f 100644 --- a/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.es.xlf +++ b/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.es.xlf @@ -227,6 +227,16 @@ Si no existe, se creará el directorio especificado. Se han omitido los argumentos siguientes: "{0}" + + No serializer registered with ID '{0}' + No serializer registered with ID '{0}' + + + + No serializer registered with type '{0}' + No serializer registered with type '{0}' + + diff --git a/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.fr.xlf b/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.fr.xlf index 793bc454eeff..ba70a45916f9 100644 --- a/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.fr.xlf +++ b/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.fr.xlf @@ -225,6 +225,16 @@ Le répertoire spécifié est créé, s'il n'existe pas déjà. Les arguments suivants ont été ignorés : "{0}" + + No serializer registered with ID '{0}' + No serializer registered with ID '{0}' + + + + No serializer registered with type '{0}' + No serializer registered with type '{0}' + + diff --git a/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.it.xlf b/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.it.xlf index f9a5a7da109e..709379b80707 100644 --- a/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.it.xlf +++ b/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.it.xlf @@ -225,6 +225,16 @@ Se non esiste, la directory specificata verrà creata. Gli argomenti seguenti sono stati ignorati: "{0}" + + No serializer registered with ID '{0}' + No serializer registered with ID '{0}' + + + + No serializer registered with type '{0}' + No serializer registered with type '{0}' + + diff --git a/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.ja.xlf b/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.ja.xlf index fec80b377d83..dfe160b76007 100644 --- a/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.ja.xlf +++ b/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.ja.xlf @@ -225,6 +225,16 @@ The specified directory will be created if it does not exist. 次の引数は無視されました: "{0}" + + No serializer registered with ID '{0}' + No serializer registered with ID '{0}' + + + + No serializer registered with type '{0}' + No serializer registered with type '{0}' + + diff --git a/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.ko.xlf b/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.ko.xlf index a4a2fbbcf50d..1089fc71633c 100644 --- a/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.ko.xlf +++ b/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.ko.xlf @@ -225,6 +225,16 @@ The specified directory will be created if it does not exist. "{0}" 인수가 무시되었습니다. + + No serializer registered with ID '{0}' + No serializer registered with ID '{0}' + + + + No serializer registered with type '{0}' + No serializer registered with type '{0}' + + diff --git a/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.pl.xlf b/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.pl.xlf index 245ade91f3db..abb95ebbc003 100644 --- a/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.pl.xlf +++ b/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.pl.xlf @@ -225,6 +225,16 @@ Jeśli określony katalog nie istnieje, zostanie utworzony. Następujące argumenty zostały zignorowane: „{0}” + + No serializer registered with ID '{0}' + No serializer registered with ID '{0}' + + + + No serializer registered with type '{0}' + No serializer registered with type '{0}' + + diff --git a/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.pt-BR.xlf b/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.pt-BR.xlf index aeac4d948a67..21cb32614177 100644 --- a/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.pt-BR.xlf +++ b/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.pt-BR.xlf @@ -225,6 +225,16 @@ O diretório especificado será criado se ele ainda não existir. Os argumentos a seguir foram ignorados: "{0}" + + No serializer registered with ID '{0}' + No serializer registered with ID '{0}' + + + + No serializer registered with type '{0}' + No serializer registered with type '{0}' + + diff --git a/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.ru.xlf b/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.ru.xlf index 8a17e4203835..8272066afb59 100644 --- a/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.ru.xlf +++ b/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.ru.xlf @@ -225,6 +225,16 @@ The specified directory will be created if it does not exist. Следующие аргументы пропущены: "{0}" + + No serializer registered with ID '{0}' + No serializer registered with ID '{0}' + + + + No serializer registered with type '{0}' + No serializer registered with type '{0}' + + diff --git a/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.tr.xlf b/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.tr.xlf index ec6f00b75dc3..5d2d1aeaefb9 100644 --- a/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.tr.xlf +++ b/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.tr.xlf @@ -225,6 +225,16 @@ Belirtilen dizin yoksa oluşturulur. Şu bağımsız değişkenler yoksayıldı: "{0}" + + No serializer registered with ID '{0}' + No serializer registered with ID '{0}' + + + + No serializer registered with type '{0}' + No serializer registered with type '{0}' + + diff --git a/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.zh-Hans.xlf b/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.zh-Hans.xlf index 6003f1c674b2..a849cd34fc8e 100644 --- a/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.zh-Hans.xlf +++ b/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.zh-Hans.xlf @@ -225,6 +225,16 @@ The specified directory will be created if it does not exist. 已忽略以下参数:“{0}” + + No serializer registered with ID '{0}' + No serializer registered with ID '{0}' + + + + No serializer registered with type '{0}' + No serializer registered with type '{0}' + + diff --git a/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.zh-Hant.xlf b/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.zh-Hant.xlf index 1caec7bb100c..0fb7e281f132 100644 --- a/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.zh-Hant.xlf +++ b/src/Cli/dotnet/commands/dotnet-test/xlf/LocalizableStrings.zh-Hant.xlf @@ -225,6 +225,16 @@ The specified directory will be created if it does not exist. 已忽略下列引數: "{0}" + + No serializer registered with ID '{0}' + No serializer registered with ID '{0}' + + + + No serializer registered with type '{0}' + No serializer registered with type '{0}' + + diff --git a/src/Layout/redist/MSBuildImports/Current/Microsoft.Common.CrossTargeting.targets/ImportAfter/Microsoft.TestPlatform.CrossTargeting.targets b/src/Layout/redist/MSBuildImports/Current/Microsoft.Common.CrossTargeting.targets/ImportAfter/Microsoft.TestPlatform.CrossTargeting.targets index 5f1a9e3deed0..c0dccc2c5812 100644 --- a/src/Layout/redist/MSBuildImports/Current/Microsoft.Common.CrossTargeting.targets/ImportAfter/Microsoft.TestPlatform.CrossTargeting.targets +++ b/src/Layout/redist/MSBuildImports/Current/Microsoft.Common.CrossTargeting.targets/ImportAfter/Microsoft.TestPlatform.CrossTargeting.targets @@ -86,8 +86,76 @@ Copyright (c) .NET Foundation. All rights reserved. - + VSTest + + + + + + + <_TestTfmsInParallel>$([MSBuild]::ValueOrDefault('$(TestTfmsInParallel)', '$(BuildInParallel)')) + + + <_TargetFramework Include="$(TargetFrameworks)" /> + <_ProjectToTestWithTFM Include="$(MSBuildProjectFile)" Properties="TargetFramework=%(_TargetFramework.Identity)" /> + + + + + + + + + + _GetTestsProject + + + + + + + diff --git a/src/Layout/redist/MSBuildImports/Current/Microsoft.Common.targets/ImportAfter/Microsoft.TestPlatform.ImportAfter.targets b/src/Layout/redist/MSBuildImports/Current/Microsoft.Common.targets/ImportAfter/Microsoft.TestPlatform.ImportAfter.targets index bfe52d7e9ed3..b1b151081fb0 100644 --- a/src/Layout/redist/MSBuildImports/Current/Microsoft.Common.targets/ImportAfter/Microsoft.TestPlatform.ImportAfter.targets +++ b/src/Layout/redist/MSBuildImports/Current/Microsoft.Common.targets/ImportAfter/Microsoft.TestPlatform.ImportAfter.targets @@ -17,4 +17,9 @@ Copyright (c) .NET Foundation. All rights reserved. $(MSBuildExtensionsPath)\Microsoft.TestPlatform.targets + + + + + diff --git a/src/Tasks/Microsoft.NET.Build.Tasks/GetTestsProject.cs b/src/Tasks/Microsoft.NET.Build.Tasks/GetTestsProject.cs new file mode 100644 index 000000000000..25d21e0a356a --- /dev/null +++ b/src/Tasks/Microsoft.NET.Build.Tasks/GetTestsProject.cs @@ -0,0 +1,45 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. + +using Microsoft.Build.Framework; +using Microsoft.DotNet.Tools.Test; + +namespace Microsoft.NET.Build.Tasks +{ + public class GetTestsProject : Microsoft.Build.Utilities.Task + { + [Required] + public ITaskItem TargetPath { get; set; } + + [Required] + public ITaskItem GetTestsProjectPipeName { get; set; } + + [Required] + public ITaskItem ProjectFullPath { get; set; } + + public override bool Execute() + { + try + { + Log.LogMessage(MessageImportance.Low, $"Target path: {TargetPath}"); + + NamedPipeClient dotnetTestPipeClient = new(GetTestsProjectPipeName.ItemSpec); + + dotnetTestPipeClient.RegisterSerializer(new ModuleSerializer(), typeof(Module)); + dotnetTestPipeClient.RegisterSerializer(new VoidResponseSerializer(), typeof(VoidResponse)); + + dotnetTestPipeClient.ConnectAsync(CancellationToken.None).GetAwaiter().GetResult(); + dotnetTestPipeClient.RequestReplyAsync(new Module(TargetPath.ItemSpec, ProjectFullPath.ItemSpec), CancellationToken.None).GetAwaiter().GetResult(); + } + catch (Exception ex) + { + Log.LogMessage(ex.Message); + + throw; + } + + return true; + } + } +} diff --git a/src/Tasks/Microsoft.NET.Build.Tasks/Microsoft.NET.Build.Tasks.csproj b/src/Tasks/Microsoft.NET.Build.Tasks/Microsoft.NET.Build.Tasks.csproj index e409542a4fad..22c24fe0f583 100644 --- a/src/Tasks/Microsoft.NET.Build.Tasks/Microsoft.NET.Build.Tasks.csproj +++ b/src/Tasks/Microsoft.NET.Build.Tasks/Microsoft.NET.Build.Tasks.csproj @@ -3,6 +3,7 @@ + false Microsoft.NET.Sdk $(Configuration)\Sdks\$(PackageId)\tools @@ -87,16 +88,17 @@ - - - - + + + + + - + @@ -112,7 +114,7 @@ - + <_NugetBuildTasksPackPath>$(NuGetPackageRoot)nuget.build.tasks.pack\$(NuGetBuildTasksPackageVersion) @@ -152,5 +154,5 @@ - +