From 28eecd0c283817ac9c2ae6d12fc152fc961bb763 Mon Sep 17 00:00:00 2001 From: Eduard Dumitru Date: Fri, 16 Aug 2024 11:37:43 +0200 Subject: [PATCH] reintroduce ConnectionFactory as BeforeConnect - simplify service client configuration - write tests --- NuGet.Config | 1 + benchmarks/NuGet.Config | 11 -- src/CoreIpc.sln | 2 +- src/UiPath.CoreIpc/Client/ServiceClient.cs | 74 ++++----- src/UiPath.CoreIpc/Config/ClientConfig.cs | 10 +- .../Config/IServiceClientConfig.cs | 11 ++ src/UiPath.CoreIpc/Config/IpcClient.cs | 2 + src/UiPath.CoreIpc/Config/ListenerConfig.cs | 14 +- src/UiPath.CoreIpc/GlobalUsings.cs | 1 + src/UiPath.CoreIpc/Server/Listener.cs | 16 +- src/UiPath.CoreIpc/Server/ServerConnection.cs | 2 +- src/UiPath.Ipc.Tests/ComputingTests.cs | 157 +++++++++++++++++- .../ComputingTestsOverNamedPipes.cs | 13 +- src/UiPath.Ipc.Tests/ComputingTestsOverTcp.cs | 14 ++ .../ComputingTestsOverWebSockets.cs | 14 ++ .../Config/OverrideConfigAttribute.cs | 4 +- src/UiPath.Ipc.Tests/GlobalUsings.cs | 1 + src/UiPath.Ipc.Tests/Helpers/IpcHelpers.cs | 7 + src/UiPath.Ipc.Tests/Helpers/TestRunId.cs | 26 +++ src/UiPath.Ipc.Tests/Helpers/Timeouts.cs | 2 +- .../Helpers/WebSocketContext.cs | 6 +- src/UiPath.Ipc.Tests/Program.cs | 33 ++++ .../Services/ComputingService.cs | 7 +- .../Services/IComputingService.cs | 2 +- .../Services/ISystemService.cs | 8 +- .../Services/SystemService.cs | 4 +- src/UiPath.Ipc.Tests/SystemTests.cs | 40 +++-- src/UiPath.Ipc.Tests/TestBase.cs | 116 ++++++------- src/UiPath.Ipc.Tests/UiPath.Ipc.Tests.csproj | 2 + 29 files changed, 437 insertions(+), 163 deletions(-) delete mode 100644 benchmarks/NuGet.Config create mode 100644 src/UiPath.CoreIpc/Config/IServiceClientConfig.cs create mode 100644 src/UiPath.Ipc.Tests/Program.cs diff --git a/NuGet.Config b/NuGet.Config index ba6d1755..437e25e1 100644 --- a/NuGet.Config +++ b/NuGet.Config @@ -2,5 +2,6 @@ + \ No newline at end of file diff --git a/benchmarks/NuGet.Config b/benchmarks/NuGet.Config deleted file mode 100644 index deec2ce6..00000000 --- a/benchmarks/NuGet.Config +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/src/CoreIpc.sln b/src/CoreIpc.sln index d863ba5d..8198f5ab 100644 --- a/src/CoreIpc.sln +++ b/src/CoreIpc.sln @@ -8,7 +8,7 @@ EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{676A208A-2F08-4749-A833-F8D2BCB1B147}" ProjectSection(SolutionItems) = preProject Directory.Build.targets = Directory.Build.targets - NuGet.Config = NuGet.Config + ..\NuGet.Config = ..\NuGet.Config EndProjectSection EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Playground", "Playground\Playground.csproj", "{F0365E40-DA73-4583-A363-89CBEF68A4C6}" diff --git a/src/UiPath.CoreIpc/Client/ServiceClient.cs b/src/UiPath.CoreIpc/Client/ServiceClient.cs index e546e4fd..12ded021 100644 --- a/src/UiPath.CoreIpc/Client/ServiceClient.cs +++ b/src/UiPath.CoreIpc/Client/ServiceClient.cs @@ -2,26 +2,8 @@ internal abstract class ServiceClient : IDisposable { - #region " NonGeneric-Generic adapter cache " - private static readonly MethodInfo GenericDefinition = ((Func>)Invoke).Method.GetGenericMethodDefinition(); - private static readonly ConcurrentDictionary ReturnTypeToInvokeDelegate = new(); - private static InvokeDelegate GetInvokeDelegate(Type returnType) => ReturnTypeToInvokeDelegate.GetOrAdd(returnType, CreateInvokeDelegate); - private static InvokeDelegate CreateInvokeDelegate(Type returnType) - => GenericDefinition.MakeGenericDelegate( - returnType.IsGenericType - ? returnType.GetGenericArguments()[0] - : typeof(object)); - - private static Task Invoke(ServiceClient serviceClient, MethodInfo method, object?[] args) => serviceClient.Invoke(method, args); - #endregion - - protected abstract TimeSpan RequestTimeout { get; } - protected abstract BeforeCallHandler? BeforeCall { get; } - protected abstract ILogger? Log { get; } - protected abstract string DebugName { get; } - protected abstract ISerializer? Serializer { get; } + protected abstract IServiceClientConfig Config { get; } public abstract Stream? Network { get; } - public event EventHandler? ConnectionClosed; private readonly Type _interfaceType; @@ -33,7 +15,6 @@ protected ServiceClient(Type interfaceType) } protected void RaiseConnectionClosed() => ConnectionClosed?.Invoke(this, EventArgs.Empty); - public virtual ValueTask CloseConnection() => throw new NotSupportedException(); public object? Invoke(MethodInfo method, object?[] args) => GetInvokeDelegate(method.ReturnType)(this, method, args); @@ -64,7 +45,7 @@ async Task Invoke() { CancellationToken cancellationToken = default; TimeSpan messageTimeout = default; - TimeSpan clientTimeout = RequestTimeout; + TimeSpan clientTimeout = Config.RequestTimeout; Stream? uploadStream = null; var methodName = method.Name; @@ -77,10 +58,10 @@ async Task Invoke() var (connection, newConnection) = await EnsureConnection(ct); - if (BeforeCall is not null) + if (Config.BeforeCall is not null) { var callInfo = new CallInfo(newConnection, method, args); - await BeforeCall(callInfo, ct); + await Config.BeforeCall(callInfo, ct); } var requestId = connection.NewRequestId(); @@ -89,11 +70,11 @@ async Task Invoke() UploadStream = uploadStream }; - Log?.ServiceClientCalling(methodName, requestId, DebugName); + Config.Logger?.ServiceClientCalling(methodName, requestId, Config.DebugName); var response = await connection.RemoteCall(request, ct); // returns user errors instead of throwing them (could throw for system bugs) - Log?.ServiceClientCalled(methodName, requestId, DebugName); + Config.Logger?.ServiceClientCalled(methodName, requestId, Config.DebugName); - return response.Deserialize(Serializer); + return response.Deserialize(Config.Serializer); } catch (Exception ex) { @@ -127,7 +108,7 @@ string[] SerializeArguments() break; } - result[index] = Serializer.OrDefault().Serialize(args[index]); + result[index] = Config.Serializer.OrDefault().Serialize(args[index]); } return result; @@ -142,9 +123,22 @@ public void Dispose() } private void Dispose(bool disposing) { - Log?.ServiceClientDispose(DebugName); + Config.Logger?.ServiceClientDispose(Config.DebugName); } - public override string ToString() => DebugName; + public override string ToString() => Config.DebugName; + + #region Generic adapter cache + private static readonly MethodInfo GenericDefinition = ((Func>)Invoke).Method.GetGenericMethodDefinition(); + private static readonly ConcurrentDictionary ReturnTypeToInvokeDelegate = new(); + private static InvokeDelegate GetInvokeDelegate(Type returnType) => ReturnTypeToInvokeDelegate.GetOrAdd(returnType, CreateInvokeDelegate); + private static InvokeDelegate CreateInvokeDelegate(Type returnType) + => GenericDefinition.MakeGenericDelegate( + returnType.IsGenericType + ? returnType.GetGenericArguments()[0] + : typeof(object)); + + private static Task Invoke(ServiceClient serviceClient, MethodInfo method, object?[] args) => serviceClient.Invoke(method, args); + #endregion } internal sealed class ServiceClientProper : ServiceClient @@ -208,10 +202,16 @@ public override async ValueTask CloseConnection() return (LatestConnection, newlyConnected: false); } - LatestConnection = new Connection(await Connect(ct), Serializer, Log, DebugName); + if (Config.BeforeConnect is not null) + { + await Config.BeforeConnect(ct); + } + + var network = await Connect(ct); + LatestConnection = new Connection(network, Config.Serializer, Config.Logger, Config.DebugName); var router = new Router(_client.Config.CreateCallbackRouterConfig(), _client.Config.ServiceProvider); _latestServer = new Server(router, _client.Config.RequestTimeout, LatestConnection); - LatestConnection.Listen().LogException(Log, DebugName); + LatestConnection.Listen().LogException(Config.Logger, Config.DebugName); return (LatestConnection, newlyConnected: true); } } @@ -228,11 +228,7 @@ private async Task Connect(CancellationToken ct) return network; } - protected override TimeSpan RequestTimeout => _client.Config.RequestTimeout; - protected override BeforeCallHandler? BeforeCall => _client.Config.BeforeCall; - protected override ILogger? Log => _client.Config.Logger; - protected override string DebugName => _client.Transport.ToString(); - protected override ISerializer? Serializer => _client.Config.Serializer; + protected override IServiceClientConfig Config => _client.Config; } internal sealed class ServiceClientForCallback : ServiceClient @@ -251,9 +247,5 @@ public ServiceClientForCallback(Connection connection, Listener listener, Type i protected override Task<(Connection connection, bool newlyConnected)> EnsureConnection(CancellationToken ct) => Task.FromResult((_connection, newlyConnected: false)); - protected override TimeSpan RequestTimeout => _listener.Config.RequestTimeout; - protected override BeforeCallHandler? BeforeCall => null; - protected override ILogger? Log => null; - protected override string DebugName => $"ReverseClient for {_listener}"; - protected override ISerializer? Serializer => null; + protected override IServiceClientConfig Config => _listener.Config; } diff --git a/src/UiPath.CoreIpc/Config/ClientConfig.cs b/src/UiPath.CoreIpc/Config/ClientConfig.cs index f2aed93a..0187092f 100644 --- a/src/UiPath.CoreIpc/Config/ClientConfig.cs +++ b/src/UiPath.CoreIpc/Config/ClientConfig.cs @@ -1,15 +1,21 @@ -namespace UiPath.Ipc; +using System.ComponentModel; -public sealed record ClientConfig : EndpointConfig +namespace UiPath.Ipc; + +public sealed record ClientConfig : EndpointConfig, IServiceClientConfig { public EndpointCollection? Callbacks { get; init; } public IServiceProvider? ServiceProvider { get; init; } public ILogger? Logger { get; init; } + public BeforeConnectHandler? BeforeConnect { get; init; } public BeforeCallHandler? BeforeCall { get; init; } public TaskScheduler? Scheduler { get; init; } public ISerializer? Serializer { get; set; } + [EditorBrowsable(EditorBrowsableState.Never)] + public string DebugName { get; set; } = null!; + internal void Validate() { var haveDeferredInjectedCallbacks = Callbacks?.Any(x => x.Service.MaybeGetServiceProvider() is null && x.Service.MaybeGetInstance() is null) ?? false; diff --git a/src/UiPath.CoreIpc/Config/IServiceClientConfig.cs b/src/UiPath.CoreIpc/Config/IServiceClientConfig.cs new file mode 100644 index 00000000..95d3a29e --- /dev/null +++ b/src/UiPath.CoreIpc/Config/IServiceClientConfig.cs @@ -0,0 +1,11 @@ +namespace UiPath.Ipc; + +internal interface IServiceClientConfig +{ + TimeSpan RequestTimeout { get; } + BeforeConnectHandler? BeforeConnect { get; } + BeforeCallHandler? BeforeCall { get; } + ILogger? Logger { get; } + ISerializer? Serializer { get; } + string DebugName { get; } +} diff --git a/src/UiPath.CoreIpc/Config/IpcClient.cs b/src/UiPath.CoreIpc/Config/IpcClient.cs index 28ff2f37..0c1735ef 100644 --- a/src/UiPath.CoreIpc/Config/IpcClient.cs +++ b/src/UiPath.CoreIpc/Config/IpcClient.cs @@ -28,5 +28,7 @@ internal void Validate() Config.Validate(); Transport.Validate(); + + Config.DebugName ??= Transport.ToString(); } } diff --git a/src/UiPath.CoreIpc/Config/ListenerConfig.cs b/src/UiPath.CoreIpc/Config/ListenerConfig.cs index cd221a62..2c41dcc6 100644 --- a/src/UiPath.CoreIpc/Config/ListenerConfig.cs +++ b/src/UiPath.CoreIpc/Config/ListenerConfig.cs @@ -2,15 +2,13 @@ namespace UiPath.Ipc; -public abstract record ListenerConfig : EndpointConfig +public abstract record ListenerConfig : EndpointConfig, IServiceClientConfig { public int ConcurrentAccepts { get; init; } = 5; public byte MaxReceivedMessageSizeInMegabytes { get; init; } = 2; public X509Certificate? Certificate { get; init; } - internal int MaxMessageSize => MaxReceivedMessageSizeInMegabytes * 1024 * 1024; - internal string DebugName => GetType().Name; internal IEnumerable Validate() => Enumerable.Empty(); internal override RouterConfig CreateRouterConfig(IpcServer server) @@ -20,4 +18,14 @@ internal override RouterConfig CreateRouterConfig(IpcServer server) { Scheduler = endpoint.Scheduler ?? server.Scheduler }); + + #region IServiceClientConfig + /// Do not implement explicitly, as it must be implicitly implemented by . + + BeforeConnectHandler? IServiceClientConfig.BeforeConnect => null; + BeforeCallHandler? IServiceClientConfig.BeforeCall => null; + ILogger? IServiceClientConfig.Logger => null; + ISerializer? IServiceClientConfig.Serializer => null!; + string IServiceClientConfig.DebugName => $"CallbackClient for {this}"; + #endregion } diff --git a/src/UiPath.CoreIpc/GlobalUsings.cs b/src/UiPath.CoreIpc/GlobalUsings.cs index c3b8f439..66625222 100644 --- a/src/UiPath.CoreIpc/GlobalUsings.cs +++ b/src/UiPath.CoreIpc/GlobalUsings.cs @@ -1,4 +1,5 @@ global using UiPath.Ipc.Extensibility; +global using BeforeConnectHandler = System.Func; global using BeforeCallHandler = System.Func; global using InvokeDelegate = System.Func; global using Accept = System.Func>; diff --git a/src/UiPath.CoreIpc/Server/Listener.cs b/src/UiPath.CoreIpc/Server/Listener.cs index 356c3056..37b9a532 100644 --- a/src/UiPath.CoreIpc/Server/Listener.cs +++ b/src/UiPath.CoreIpc/Server/Listener.cs @@ -82,7 +82,7 @@ protected Listener(IpcServer server, ListenerConfig config) { Config = config; Server = server; - Logger = server.ServiceProvider.GetRequiredService().CreateLogger(config.DebugName); + Logger = server.ServiceProvider.GetRequiredService().CreateLogger(categoryName: config.ToString()); _disposeTask = new(DisposeCore); } @@ -131,7 +131,7 @@ public void LogError(Exception exception, string message) protected override async Task DisposeCore() { - Log($"Stopping listener {Config.DebugName}..."); + Log($"Stopping listener {Config}..."); _cts.Cancel(); try { @@ -139,11 +139,11 @@ protected override async Task DisposeCore() } catch (OperationCanceledException ex) when (ex.CancellationToken == _cts.Token) { - Log($"Stopping listener {Config.DebugName} threw OCE."); + Log($"Stopping listener {Config} threw OCE."); } catch (Exception ex) { - LogError(ex, $"Stopping listener {Config.DebugName} failed."); + LogError(ex, $"Stopping listener {Config} failed."); } await State.DisposeAsync(); _cts.Dispose(); @@ -151,7 +151,7 @@ protected override async Task DisposeCore() private async Task Listen(CancellationToken ct) { - Log($"Starting listener {Config.DebugName}..."); + Log($"Starting listener {Config}..."); await Task.WhenAll(Enumerable.Range(1, Config.ConcurrentAccepts).Select(async _ => { @@ -167,17 +167,15 @@ private async Task AcceptConnection(CancellationToken ct) try { var network = await serverConnection.AcceptClient(ct); - serverConnection.Listen(network, ct).LogException(Logger, Config.DebugName); + serverConnection.Listen(network, ct).LogException(Logger, Config); } catch (Exception ex) { serverConnection.Dispose(); if (!ct.IsCancellationRequested) { - Logger.LogException(ex, Config.DebugName); + Logger.LogException(ex, Config); } } } - - public override string ToString() => Config.ToString(); } diff --git a/src/UiPath.CoreIpc/Server/ServerConnection.cs b/src/UiPath.CoreIpc/Server/ServerConnection.cs index 20be603b..bc4de3e8 100644 --- a/src/UiPath.CoreIpc/Server/ServerConnection.cs +++ b/src/UiPath.CoreIpc/Server/ServerConnection.cs @@ -69,7 +69,7 @@ TCallbackInterface IClient.GetCallback() where TCallbackInte TCallbackInterface CreateCallback(Type callbackContract) { - Listener.Logger.LogInformation($"Create callback {callbackContract} {Listener.Config.DebugName}"); + Listener.Logger.LogInformation($"Create callback {callbackContract} {Listener.Config}"); _connectionAsTask ??= Task.FromResult(Connection!); diff --git a/src/UiPath.Ipc.Tests/ComputingTests.cs b/src/UiPath.Ipc.Tests/ComputingTests.cs index 1fea8e13..49a79ec8 100644 --- a/src/UiPath.Ipc.Tests/ComputingTests.cs +++ b/src/UiPath.Ipc.Tests/ComputingTests.cs @@ -1,4 +1,12 @@ -using System.Collections.Concurrent; +using Newtonsoft.Json; +using Nito.Disposables; +using System.Collections.Concurrent; +using System.Runtime.InteropServices; +using System.Text; +using UiPath.Ipc.Transport.NamedPipe; +using UiPath.Ipc.Transport.Tcp; +using UiPath.Ipc.Transport.WebSocket; +using Xunit; using Xunit.Abstractions; namespace UiPath.Ipc.Tests; @@ -9,12 +17,12 @@ public abstract class ComputingTests : TestBase protected readonly ComputingCallback _computingCallback = new(); private readonly Lazy _service; - private readonly Lazy _proxy; + private readonly Lazy _proxy; protected ComputingService Service => _service.Value; - protected IComputingService Proxy => _proxy.Value; + protected IComputingService Proxy => _proxy.Value!; - protected sealed override IpcProxy IpcProxy => Proxy as IpcProxy ?? throw new InvalidOperationException($"Proxy was expected to be a {nameof(IpcProxy)} but was not."); + protected sealed override IpcProxy? IpcProxy => Proxy as IpcProxy; protected sealed override Type ContractType => typeof(IComputingService); protected readonly ConcurrentBag _clientBeforeCalls = new(); @@ -82,7 +90,7 @@ public async Task ClientTimeouts_ShouldWork() await Proxy.Wait(Timeout.InfiniteTimeSpan).ShouldThrowAsync(); await Proxy.GetCallbackThreadName( - duration: TimeSpan.Zero, + waitOnServer: TimeSpan.Zero, message: new() { RequestTimeout = Timeouts.DefaultRequest @@ -93,7 +101,7 @@ await Proxy.GetCallbackThreadName( private sealed class ShortClientTimeout : OverrideConfig { - public override IpcClient Override(IpcClient client) => client.WithRequestTimeout(TimeSpan.FromMilliseconds(10)); + public override IpcClient? Override(Func client) => client().WithRequestTimeout(TimeSpan.FromMilliseconds(10)); } [Theory, IpcAutoData] @@ -102,7 +110,7 @@ public async Task CallsWithArraysOfStructsAsParams_ShouldWork(ComplexNumber a, C [Fact] public async Task Callbacks_ShouldWork() - => await Proxy.GetCallbackThreadName(duration: TimeSpan.Zero).ShouldBeAsync(Names.GuiThreadName); + => await Proxy.GetCallbackThreadName(waitOnServer: TimeSpan.Zero).ShouldBeAsync(Names.GuiThreadName); [Fact] public async Task CallbacksWithParams_ShouldWork() @@ -141,4 +149,139 @@ public async Task ServerBeforeCall_WhenSync_ShouldShareAsyncLocalContextWithTheT await Proxy.GetCallContext().ShouldBeAsync(expectedCallContext); } + + [Fact] + [OverrideConfig(typeof(SetBeforeConnect))] + public async Task BeforeConnect_ShouldWork() + { + int callCount = 0; + SetBeforeConnect.Set(async _ => callCount++); + + await Proxy.AddFloats(1, 2).ShouldBeAsync(3); + callCount.ShouldBe(1); + + await Proxy.AddFloats(1, 2).ShouldBeAsync(3); + callCount.ShouldBe(1); + + await IpcProxy.CloseConnection(); + await Proxy.AddFloats(1, 2).ShouldBeAsync(3); + callCount.ShouldBe(2); + } + + private sealed class SetBeforeConnect : OverrideConfig + { + private static readonly AsyncLocal ValueStorage = new(); + public static void Set(BeforeConnectHandler value) => ValueStorage.Value = value; + + public override IpcClient? Override(Func client) + => client().WithBeforeConnect(ct => ValueStorage.Value.ShouldNotBeNull().Invoke(ct)); + } + +#if !NET461 + [SkippableFact] +#endif + [OverrideConfig(typeof(DisableInProcClientServer))] + public async Task BeforeConnect_ShouldStartExternalServerJIT() + { + Skip.IfNot(RuntimeInformation.IsOSPlatform(OSPlatform.Windows), "Test works only on Windows."); + + using var whereDotNet = new Process + { + StartInfo = + { + FileName = "where.exe", + Arguments = "dotnet.exe", + } + }; + var pathDotNet = await whereDotNet.RunReturnStdOut(); + + var externalServerParams = RandomServerParams(); + var arg = Convert.ToBase64String(Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(externalServerParams))); + + var pipeName = $"{Guid.NewGuid():N}"; + + using var serverProcess = new Process + { + StartInfo = + { + FileName = pathDotNet, + Arguments = $"\"{Assembly.GetExecutingAssembly().Location}\" {arg}", + UseShellExecute = false, + }, + }; + using var killProcess = new Disposable(() => + { + try + { + serverProcess.Kill(); + } + catch + { + } + _outputHelper.WriteLine("Killed server process"); + }); + var proxy = new IpcClient + { + Config = new() + { + Scheduler = GuiScheduler, + BeforeConnect = async (_) => + { + serverProcess.Start(); + var time = TimeSpan.FromSeconds(1); + _outputHelper.WriteLine($"Server started. Waiting {time}. PID={serverProcess.Id}"); + await Task.Delay(time); + }, + }, + Transport = externalServerParams.CreateClientTransport() + }.GetProxy(); + + await proxy.AddFloats(1, 2).ShouldBeAsync(3); + } + + public abstract IAsyncDisposable? RandomTransportPair(out ListenerConfig listener, out ClientTransport transport); + + public abstract ExternalServerParams RandomServerParams(); + public readonly record struct ExternalServerParams(ServerKind Kind, string? PipeName = null, int Port = 0) + { + public IAsyncDisposable? CreateListenerConfig(out ListenerConfig listenerConfig) + { + switch (Kind) + { + case ServerKind.NamedPipes: + { + listenerConfig = new NamedPipeListener() { PipeName = PipeName! }; + return null; + } + case ServerKind.Tcp: + { + listenerConfig = new TcpListener() { EndPoint = new(System.Net.IPAddress.Loopback, Port) }; + return null; + } + case ServerKind.WebSockets: + { + var context = new WebSocketContext(Port); + listenerConfig = new WebSocketListener { Accept = context.Accept }; + return context; + } + default: + throw new NotSupportedException($"Kind not supported. Kind was {Kind}"); + } + } + + public ClientTransport CreateClientTransport() => Kind switch + { + ServerKind.NamedPipes => new NamedPipeTransport() { PipeName = PipeName! }, + ServerKind.Tcp => new TcpTransport() { EndPoint = new(System.Net.IPAddress.Loopback, Port) }, + ServerKind.WebSockets => new WebSocketTransport() { Uri = new($"ws://localhost:{Port}") }, + _ => throw new NotSupportedException($"Kind not supported. Kind was {Kind}") + }; + } + public enum ServerKind { NamedPipes, Tcp, WebSockets } + + private sealed class DisableInProcClientServer : OverrideConfig + { + public override ListenerConfig? Override(Func listener) => null; + public override IpcClient? Override(Func client) => null; + } } diff --git a/src/UiPath.Ipc.Tests/ComputingTestsOverNamedPipes.cs b/src/UiPath.Ipc.Tests/ComputingTestsOverNamedPipes.cs index 8811da92..6378a339 100644 --- a/src/UiPath.Ipc.Tests/ComputingTestsOverNamedPipes.cs +++ b/src/UiPath.Ipc.Tests/ComputingTestsOverNamedPipes.cs @@ -17,5 +17,16 @@ public ComputingTestsOverNamedPipes(ITestOutputHelper outputHelper) : base(outpu { PipeName = PipeName, AllowImpersonation = true, - }; + }; + + public override IAsyncDisposable? RandomTransportPair(out ListenerConfig listener, out ClientTransport transport) + { + var pipeName = $"{Guid.NewGuid():N}"; + listener = new NamedPipeListener() { PipeName = pipeName }; + transport = new NamedPipeTransport() { PipeName = pipeName }; + return null; + } + + public override ExternalServerParams RandomServerParams() + => new(ServerKind.NamedPipes, PipeName: $"{Guid.NewGuid():N}"); } diff --git a/src/UiPath.Ipc.Tests/ComputingTestsOverTcp.cs b/src/UiPath.Ipc.Tests/ComputingTestsOverTcp.cs index d6fdf0ae..3441fb09 100644 --- a/src/UiPath.Ipc.Tests/ComputingTestsOverTcp.cs +++ b/src/UiPath.Ipc.Tests/ComputingTestsOverTcp.cs @@ -18,4 +18,18 @@ protected override ListenerConfig CreateListener() protected override ClientTransport CreateClientTransport() => new TcpTransport() { EndPoint = _endPoint }; + + public override IAsyncDisposable? RandomTransportPair(out ListenerConfig listener, out ClientTransport transport) + { + var endPoint = NetworkHelper.FindFreeLocalPort(); + listener = new TcpListener() { EndPoint = endPoint }; + transport = new TcpTransport() { EndPoint = endPoint }; + return null; + } + + public override ExternalServerParams RandomServerParams() + { + var endPoint = NetworkHelper.FindFreeLocalPort(); + return new(ServerKind.Tcp, Port: endPoint.Port); + } } diff --git a/src/UiPath.Ipc.Tests/ComputingTestsOverWebSockets.cs b/src/UiPath.Ipc.Tests/ComputingTestsOverWebSockets.cs index fd571df5..c342db9d 100644 --- a/src/UiPath.Ipc.Tests/ComputingTestsOverWebSockets.cs +++ b/src/UiPath.Ipc.Tests/ComputingTestsOverWebSockets.cs @@ -21,4 +21,18 @@ protected override async Task DisposeAsync() }; protected override ClientTransport CreateClientTransport() => new WebSocketTransport() { Uri = _webSocketContext.ClientUri }; + + public override IAsyncDisposable? RandomTransportPair(out ListenerConfig listener, out ClientTransport transport) + { + var context = new WebSocketContext(); + listener = new WebSocketListener() { Accept = context.Accept }; + transport = new WebSocketTransport() { Uri = context.ClientUri }; + return context; + } + + public override ExternalServerParams RandomServerParams() + { + var endPoint = NetworkHelper.FindFreeLocalPort(); + return new(ServerKind.WebSockets, Port: endPoint.Port); + } } \ No newline at end of file diff --git a/src/UiPath.Ipc.Tests/Config/OverrideConfigAttribute.cs b/src/UiPath.Ipc.Tests/Config/OverrideConfigAttribute.cs index 102278c8..9435de29 100644 --- a/src/UiPath.Ipc.Tests/Config/OverrideConfigAttribute.cs +++ b/src/UiPath.Ipc.Tests/Config/OverrideConfigAttribute.cs @@ -34,6 +34,6 @@ public OverrideConfigAttribute(Type overrideConfigType) public abstract class OverrideConfig { - public virtual ListenerConfig Override(ListenerConfig listener) => listener; - public virtual IpcClient Override(IpcClient client) => client; + public virtual ListenerConfig? Override(Func listener) => listener(); + public virtual IpcClient? Override(Func client) => client(); } \ No newline at end of file diff --git a/src/UiPath.Ipc.Tests/GlobalUsings.cs b/src/UiPath.Ipc.Tests/GlobalUsings.cs index d4b046bf..1f26ad7a 100644 --- a/src/UiPath.Ipc.Tests/GlobalUsings.cs +++ b/src/UiPath.Ipc.Tests/GlobalUsings.cs @@ -1 +1,2 @@ global using Accept = System.Func>; +global using BeforeConnectHandler = System.Func; diff --git a/src/UiPath.Ipc.Tests/Helpers/IpcHelpers.cs b/src/UiPath.Ipc.Tests/Helpers/IpcHelpers.cs index d3263494..399acc8a 100644 --- a/src/UiPath.Ipc.Tests/Helpers/IpcHelpers.cs +++ b/src/UiPath.Ipc.Tests/Helpers/IpcHelpers.cs @@ -48,4 +48,11 @@ public static IpcClient WithCallbacks(this IpcClient ipcClient, EndpointCollecti Config = ipcClient.Config with { Callbacks = callbacks }, Transport = ipcClient.Transport, }; + + public static IpcClient WithBeforeConnect(this IpcClient ipcClient, BeforeConnectHandler beforeConnect) + => new() + { + Config = ipcClient.Config with { BeforeConnect = beforeConnect }, + Transport = ipcClient.Transport, + }; } \ No newline at end of file diff --git a/src/UiPath.Ipc.Tests/Helpers/TestRunId.cs b/src/UiPath.Ipc.Tests/Helpers/TestRunId.cs index d8a43d65..f76d43c8 100644 --- a/src/UiPath.Ipc.Tests/Helpers/TestRunId.cs +++ b/src/UiPath.Ipc.Tests/Helpers/TestRunId.cs @@ -4,3 +4,29 @@ public readonly record struct TestRunId(Guid Value) { public static TestRunId New() => new(Guid.NewGuid()); } + +public static class ProcessHelper +{ + public static async Task RunReturnStdOut(this Process process) + { + process.StartInfo.UseShellExecute = false; + process.StartInfo.RedirectStandardOutput = true; + process.StartInfo.RedirectStandardError = true; + + TaskCompletionSource tcsProcessExited = new(); + process.EnableRaisingEvents = true; + process.Exited += (_, _) => tcsProcessExited.SetResult(null); + + _ = process.Start(); + await tcsProcessExited.Task; + + var stdOut = await process.StandardOutput.ReadToEndAsync(); + if (process.ExitCode is not 0) + { + var stdErr = await process.StandardError.ReadToEndAsync(); + throw new InvalidOperationException($"The process exited with a non zero code. ExitCode={process.ExitCode}\r\nStdOut:\r\n{stdOut}\r\n\r\nStdErr:\r\n{stdErr}"); + } + + return stdOut; + } +} \ No newline at end of file diff --git a/src/UiPath.Ipc.Tests/Helpers/Timeouts.cs b/src/UiPath.Ipc.Tests/Helpers/Timeouts.cs index 7a2c770d..d09e1e2e 100644 --- a/src/UiPath.Ipc.Tests/Helpers/Timeouts.cs +++ b/src/UiPath.Ipc.Tests/Helpers/Timeouts.cs @@ -8,5 +8,5 @@ internal static class Timeouts public static readonly TimeSpan Short = TimeSpan.FromMilliseconds(300); - public static readonly TimeSpan DefaultRequest = Debugger.IsAttached ? TimeSpan.FromDays(1) : TimeSpan.FromSeconds(5); + public static readonly TimeSpan DefaultRequest = Debugger.IsAttached ? TimeSpan.FromDays(1) : TimeSpan.FromMinutes(1); } diff --git a/src/UiPath.Ipc.Tests/Helpers/WebSocketContext.cs b/src/UiPath.Ipc.Tests/Helpers/WebSocketContext.cs index 9f7b5ba5..364fae68 100644 --- a/src/UiPath.Ipc.Tests/Helpers/WebSocketContext.cs +++ b/src/UiPath.Ipc.Tests/Helpers/WebSocketContext.cs @@ -7,13 +7,13 @@ internal sealed class WebSocketContext : IAsyncDisposable public Accept Accept => _httpListener.Accept; public Uri ClientUri { get; } - public WebSocketContext() + public WebSocketContext(int? port = null) { - var port = NetworkHelper.FindFreeLocalPort().Port; + var actualPort = port ?? NetworkHelper.FindFreeLocalPort().Port; ClientUri = Uri("ws"); _httpListener = new(uriPrefix: Uri("http").ToString()); - Uri Uri(string scheme) => new UriBuilder(scheme, "localhost", port).Uri; + Uri Uri(string scheme) => new UriBuilder(scheme, "localhost", actualPort).Uri; } public ValueTask DisposeAsync() => _httpListener.DisposeAsync(); diff --git a/src/UiPath.Ipc.Tests/Program.cs b/src/UiPath.Ipc.Tests/Program.cs new file mode 100644 index 00000000..6e917ad3 --- /dev/null +++ b/src/UiPath.Ipc.Tests/Program.cs @@ -0,0 +1,33 @@ +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; +using System.Text; +using UiPath.Ipc; +using UiPath.Ipc.Tests; + +if (args is not [var base64]) +{ + Console.Error.WriteLine($"Usage: dotnet {Path.GetFileName(Assembly.GetEntryAssembly()!.Location)} "); + return 1; +} +var externalServerParams = JsonConvert.DeserializeObject(Encoding.UTF8.GetString(Convert.FromBase64String(base64))); +await using var asyncDisposable = externalServerParams.CreateListenerConfig(out var listener); + +await using var serviceProvider = new ServiceCollection() + .AddLogging(builder => builder.AddConsole()) + .AddSingleton() + .BuildServiceProvider(); + +await using var ipcServer = new IpcServer() +{ + ServiceProvider = serviceProvider, + Scheduler = new ConcurrentExclusiveSchedulerPair().ExclusiveScheduler, + Endpoints = new() + { + { typeof(IComputingService) }, + }, + Listeners = [listener], +}; +ipcServer.Start(); +await ipcServer.WaitForStop(); + +return 0; \ No newline at end of file diff --git a/src/UiPath.Ipc.Tests/Services/ComputingService.cs b/src/UiPath.Ipc.Tests/Services/ComputingService.cs index 7f34afa4..32ea7a7f 100644 --- a/src/UiPath.Ipc.Tests/Services/ComputingService.cs +++ b/src/UiPath.Ipc.Tests/Services/ComputingService.cs @@ -1,5 +1,4 @@ - -using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging; namespace UiPath.Ipc.Tests; @@ -30,9 +29,9 @@ public async Task Wait(TimeSpan duration, CancellationToken ct = default) return true; } - public async Task GetCallbackThreadName(TimeSpan duration, Message message = null!, CancellationToken cancellationToken = default) + public async Task GetCallbackThreadName(TimeSpan waitOnServer, Message message = null!, CancellationToken cancellationToken = default) { - await Task.Delay(duration); + await Task.Delay(waitOnServer); return await message.GetCallback().GetThreadName(); } diff --git a/src/UiPath.Ipc.Tests/Services/IComputingService.cs b/src/UiPath.Ipc.Tests/Services/IComputingService.cs index 1db7cefb..dd5bce90 100644 --- a/src/UiPath.Ipc.Tests/Services/IComputingService.cs +++ b/src/UiPath.Ipc.Tests/Services/IComputingService.cs @@ -10,7 +10,7 @@ public interface IComputingService : IComputingServiceBase { Task AddComplexNumbers(ComplexNumber a, ComplexNumber b); Task Wait(TimeSpan duration, CancellationToken ct = default); - Task GetCallbackThreadName(TimeSpan duration, Message message = null!, CancellationToken cancellationToken = default); + Task GetCallbackThreadName(TimeSpan waitOnServer, Message message = null!, CancellationToken cancellationToken = default); Task AddComplexNumberList(IReadOnlyList numbers); Task MultiplyInts(int x, int y, Message message = null!); Task GetCallContext(); diff --git a/src/UiPath.Ipc.Tests/Services/ISystemService.cs b/src/UiPath.Ipc.Tests/Services/ISystemService.cs index c554c49b..574b9abd 100644 --- a/src/UiPath.Ipc.Tests/Services/ISystemService.cs +++ b/src/UiPath.Ipc.Tests/Services/ISystemService.cs @@ -3,12 +3,12 @@ public interface ISystemService { /// - /// Returns the after the is ellapsed. + /// Returns the after the is ellapsed. /// - /// The duration to wait before completing the operation. + /// The duration to wait before completing the operation. /// A to cancel the operation. - /// A task that completes successfully with a null result, after the specified , or is canceled when the passed is signaled. - Task EchoGuidAfter(Guid value, TimeSpan duration, CancellationToken ct = default); + /// A task that completes successfully with a null result, after the specified , or is canceled when the passed is signaled. + Task EchoGuidAfter(Guid value, TimeSpan waitOnServer, Message? message = null, CancellationToken ct = default); /// /// Returns true if the received is not null. diff --git a/src/UiPath.Ipc.Tests/Services/SystemService.cs b/src/UiPath.Ipc.Tests/Services/SystemService.cs index 84479f61..9a9ce345 100644 --- a/src/UiPath.Ipc.Tests/Services/SystemService.cs +++ b/src/UiPath.Ipc.Tests/Services/SystemService.cs @@ -5,9 +5,9 @@ namespace UiPath.Ipc.Tests; public sealed class SystemService : ISystemService { - public async Task EchoGuidAfter(Guid value, TimeSpan duration, CancellationToken ct = default) + public async Task EchoGuidAfter(Guid value, TimeSpan waitOnServer, Message? message = null, CancellationToken ct = default) { - await Task.Delay(duration, ct); + await Task.Delay(waitOnServer, ct); return value; } diff --git a/src/UiPath.Ipc.Tests/SystemTests.cs b/src/UiPath.Ipc.Tests/SystemTests.cs index aec5837f..b824e23d 100644 --- a/src/UiPath.Ipc.Tests/SystemTests.cs +++ b/src/UiPath.Ipc.Tests/SystemTests.cs @@ -8,10 +8,10 @@ public abstract class SystemTests : TestBase { #region " Setup " private readonly Lazy _service; - private readonly Lazy _proxy; + private readonly Lazy _proxy; protected SystemService Service => _service.Value; - protected ISystemService Proxy => _proxy.Value; + protected ISystemService Proxy => _proxy.Value!; protected sealed override IpcProxy IpcProxy => Proxy as IpcProxy ?? throw new InvalidOperationException($"Proxy was expected to be a {nameof(IpcProxy)} but was not."); protected sealed override Type ContractType => typeof(ISystemService); @@ -48,7 +48,7 @@ public async Task PassingArgsAndReturning_ShouldWork(Guid guid) public async Task ConcurrentOperations_ShouldWork(Guid guid1, Guid guid2) { using var cts = new CancellationTokenSource(); - var task1 = Proxy.EchoGuidAfter(guid1, Timeout.InfiniteTimeSpan, cts.Token); + var task1 = Proxy.EchoGuidAfter(guid1, Timeout.InfiniteTimeSpan, message: null, cts.Token); (await Proxy.EchoGuidAfter(guid2, TimeSpan.Zero)).ShouldBe(guid2); @@ -85,16 +85,16 @@ public async Task ClientWaitingForTooLongACall_ShouldThrowTimeout() private sealed class ServerExecutingTooLongACall_ShouldThrowTimeout_Config : OverrideConfig { - public override ListenerConfig Override(ListenerConfig listener) => listener with { RequestTimeout = Timeouts.Short }; - public override IpcClient Override(IpcClient client) - => client.WithRequestTimeout(Timeout.InfiniteTimeSpan); + public override ListenerConfig? Override(Func listener) => listener() with { RequestTimeout = Timeouts.Short }; + public override IpcClient? Override(Func client) + => client().WithRequestTimeout(Timeout.InfiniteTimeSpan); } private sealed class ClientWaitingForTooLongACall_ShouldThrowTimeout_Config : OverrideConfig { - public override ListenerConfig Override(ListenerConfig listener) => listener with { RequestTimeout = Timeout.InfiniteTimeSpan }; - public override IpcClient Override(IpcClient client) - => client.WithRequestTimeout(Timeouts.IpcRoundtrip); + public override ListenerConfig? Override(Func listener) => listener() with { RequestTimeout = Timeout.InfiniteTimeSpan }; + public override IpcClient? Override(Func client) + => client().WithRequestTimeout(Timeouts.IpcRoundtrip); } private ListenerConfig ShortClientTimeout(ListenerConfig listener) => listener with { RequestTimeout = TimeSpan.FromMilliseconds(100) }; @@ -157,8 +157,8 @@ public async Task ServerCallingMultipleCallbackTypes_ShouldWork() private sealed class RegisterCallbacks : OverrideConfig { - public override IpcClient Override(IpcClient client) - => client.WithCallbacks(new() + public override IpcClient? Override(Func client) + => client().WithCallbacks(new() { { typeof(IComputingCallback), new ComputingCallback() }, { typeof(IArithmeticCallback), new ArithmeticCallback() }, @@ -214,7 +214,7 @@ await taskUploading .ShouldThrowAsync() .ShouldCompleteInAsync(Timeouts.Short); // in-process scheduling fast - await Proxy.EchoGuidAfter(guid, duration: TimeSpan.Zero) // we expect the connection to recover + await Proxy.EchoGuidAfter(guid, waitOnServer: TimeSpan.Zero) // we expect the connection to recover .ShouldBeAsync(guid); IpcProxy.Network.ShouldNotBeNull().ShouldNotBeSameAs(networkBeforeCancel); // and the network to be a new one @@ -261,8 +261,20 @@ public async Task StreamDownloadsLeftOpen_WillHijackTheConnection(string str, Gu await new StreamReader(stream).ReadToEndAsync() .ShouldBeAsync(str); - await Proxy.EchoGuidAfter(guid, TimeSpan.Zero) - .ShouldStallForAtLeastAsync(Timeouts.IpcRoundtrip + Timeouts.IpcRoundtrip); + await Proxy.EchoGuidAfter(guid, waitOnServer: TimeSpan.Zero, message: new() { RequestTimeout = Timeout.InfiniteTimeSpan}) + .ShouldStallForAtLeastAsync(Timeouts.IpcRoundtrip); + } + } + +#if !CI + [Theory, IpcAutoData] +#endif + public async Task StreamDownloadsLeftOpen_WillHijackTheConnection_Repeat(string str, Guid guid) + { + const int IterationCount = 20; + foreach (var i in Enumerable.Range(0, IterationCount)) + { + await StreamDownloadsLeftOpen_WillHijackTheConnection(str, guid); } } diff --git a/src/UiPath.Ipc.Tests/TestBase.cs b/src/UiPath.Ipc.Tests/TestBase.cs index 70518a36..6c633fbb 100644 --- a/src/UiPath.Ipc.Tests/TestBase.cs +++ b/src/UiPath.Ipc.Tests/TestBase.cs @@ -7,19 +7,19 @@ namespace UiPath.Ipc.Tests; public abstract class TestBase : IAsyncLifetime { - private readonly ITestOutputHelper _outputHelper; + protected readonly ITestOutputHelper _outputHelper; private readonly IMethodInfo _xUnitMethod; private readonly ServiceProvider _serviceProvider; private readonly AsyncContext _guiThread = new AsyncContextThread().Context; - private readonly Lazy _ipcServer; - private readonly Lazy _ipcClient; + private readonly Lazy _ipcServer; + private readonly Lazy _ipcClient; private readonly OverrideConfig? _overrideConfig; protected TestRunId TestRunId { get; } = TestRunId.New(); protected IServiceProvider ServiceProvider => _serviceProvider; protected TaskScheduler GuiScheduler => _guiThread.Scheduler; - protected IpcServer IpcServer => _ipcServer.Value; - protected abstract IpcProxy IpcProxy { get; } + protected IpcServer? IpcServer => _ipcServer.Value; + protected abstract IpcProxy? IpcProxy { get; } protected abstract Type ContractType { get; } protected readonly ConcurrentBag _serverBeforeCalls = new(); @@ -65,61 +65,71 @@ public TestBase(ITestOutputHelper outputHelper) } } - private ListenerConfig CreateListenerAndConfigure() + private ListenerConfig? CreateListenerAndConfigure() { - _outputHelper.WriteLine("Creating listener..."); - _outputHelper.WriteLine(" - Creating transport specific listener..."); - var listener = CreateListener(); - _outputHelper.WriteLine($" Result:\r\n\t\t{listener}"); - _outputHelper.WriteLine(" - Applying transport agnostic configuration..."); - listener = ConfigTransportAgnostic(listener); - _outputHelper.WriteLine($" Result:\r\n\t\t{listener}"); - if (_overrideConfig is null) + var factory = () => { - _outputHelper.WriteLine($" - No configuration override found for method {CustomTestFramework.Context?.Method.Name}"); - } - else + _outputHelper.WriteLine("Creating listener..."); + var listener = CreateListener(); + listener = ConfigTransportAgnostic(listener); + return listener; + }; + + if (_overrideConfig is null) { - _outputHelper.WriteLine($" - Applying configuration override provided by \"{_overrideConfig.GetType().Name}\" ..."); + return factory(); } - listener = _overrideConfig?.Override(listener) ?? listener; - _outputHelper.WriteLine($" Result:\r\n\t\t{listener}\r\n"); - return listener; + + return _overrideConfig.Override(factory); } - private IpcServer CreateServer() - => new() + protected IpcServer? CreateServer() { - Endpoints = new() { - new EndpointSettings(ContractType) - { - BeforeCall = (callInfo, ct) => + if (CreateListenerAndConfigure() is not { } listener) return null; + + return new() + { + Endpoints = new() { + new EndpointSettings(ContractType) { - _serverBeforeCalls.Add(callInfo); - return _tailBeforeCall?.Invoke(callInfo, ct) ?? Task.CompletedTask; + BeforeCall = (callInfo, ct) => + { + _serverBeforeCalls.Add(callInfo); + return _tailBeforeCall?.Invoke(callInfo, ct) ?? Task.CompletedTask; + } } - } - }, - Listeners = [CreateListenerAndConfigure()], - ServiceProvider = _serviceProvider, - Scheduler = GuiScheduler - }; - private IpcClient CreateClient() + }, + Listeners = [listener], + ServiceProvider = _serviceProvider, + Scheduler = GuiScheduler + }; + } + + private IpcClient? CreateClient() { - var config = CreateClientConfig(); - var transport = CreateClientTransport(); - var client = new IpcClient + var factory = () => { - Config = config, - Transport = transport + var config = CreateClientConfig(); + var transport = CreateClientTransport(); + var client = new IpcClient + { + Config = config, + Transport = transport + }; + return client; }; - client = _overrideConfig?.Override(client) ?? client; - return client; + + if (_overrideConfig is null) + { + return factory(); + } + + return _overrideConfig.Override(factory); } - private TContract GetProxy() where TContract : class - => _ipcClient.Value.GetProxy(); + private TContract? GetProxy() where TContract : class + => _ipcClient.Value?.GetProxy(); - protected void CreateLazyProxy(out Lazy lazy) where TContract : class => lazy = new(GetProxy); + protected void CreateLazyProxy(out Lazy lazy) where TContract : class => lazy = new(GetProxy); protected abstract ListenerConfig CreateListener(); @@ -130,22 +140,16 @@ private TContract GetProxy() where TContract : class protected virtual async Task DisposeAsync() { - IpcProxy.Dispose(); - await IpcProxy.CloseConnection(); - await IpcServer.DisposeAsync(); + IpcProxy?.Dispose(); + await (IpcProxy?.CloseConnection() ?? default); + await (IpcServer?.DisposeAsync() ?? default); _guiThread.Dispose(); await _serviceProvider.DisposeAsync(); } - private ITest GetTestInstance() - { - var type = _outputHelper.GetType(); - var testMember = type.GetField("test", BindingFlags.Instance | BindingFlags.NonPublic)!; - return (ITest)testMember.GetValue(_outputHelper)!; - } async Task IAsyncLifetime.InitializeAsync() { - IpcServer.Start(); + IpcServer?.Start(); } Task IAsyncLifetime.DisposeAsync() => DisposeAsync(); diff --git a/src/UiPath.Ipc.Tests/UiPath.Ipc.Tests.csproj b/src/UiPath.Ipc.Tests/UiPath.Ipc.Tests.csproj index 323b8b38..6474b558 100644 --- a/src/UiPath.Ipc.Tests/UiPath.Ipc.Tests.csproj +++ b/src/UiPath.Ipc.Tests/UiPath.Ipc.Tests.csproj @@ -1,6 +1,7 @@  + WinExe net6.0;net461 UiPath.Ipc.Tests $(NoWarn);1998 @@ -35,6 +36,7 @@ +