diff --git a/src/SharpIDE.Application/Features/Testing/Client/Dtos/AttachDebuggerInfo.cs b/src/SharpIDE.Application/Features/Testing/Client/Dtos/AttachDebuggerInfo.cs
new file mode 100644
index 0000000..51aa717
--- /dev/null
+++ b/src/SharpIDE.Application/Features/Testing/Client/Dtos/AttachDebuggerInfo.cs
@@ -0,0 +1,7 @@
+using Newtonsoft.Json;
+
+namespace SharpIDE.Application.Features.Testing.Client.Dtos;
+
+public sealed record AttachDebuggerInfo(
+ [property:JsonProperty("processId")]
+ int ProcessId);
diff --git a/src/SharpIDE.Application/Features/Testing/Client/Dtos/ClientCapabilities.cs b/src/SharpIDE.Application/Features/Testing/Client/Dtos/ClientCapabilities.cs
new file mode 100644
index 0000000..5f02010
--- /dev/null
+++ b/src/SharpIDE.Application/Features/Testing/Client/Dtos/ClientCapabilities.cs
@@ -0,0 +1,13 @@
+
+
+using Newtonsoft.Json;
+
+namespace SharpIDE.Application.Features.Testing.Client.Dtos;
+
+public sealed record ClientCapabilities(
+ [property: JsonProperty("testing")]
+ ClientTestingCapabilities Testing);
+
+public sealed record ClientTestingCapabilities(
+ [property: JsonProperty("debuggerProvider")]
+ bool DebuggerProvider);
diff --git a/src/SharpIDE.Application/Features/Testing/Client/Dtos/ClientInfo.cs b/src/SharpIDE.Application/Features/Testing/Client/Dtos/ClientInfo.cs
new file mode 100644
index 0000000..00d0792
--- /dev/null
+++ b/src/SharpIDE.Application/Features/Testing/Client/Dtos/ClientInfo.cs
@@ -0,0 +1,12 @@
+
+
+using Newtonsoft.Json;
+
+namespace SharpIDE.Application.Features.Testing.Client.Dtos;
+
+public sealed record ClientInfo(
+ [property:JsonProperty("name")]
+ string Name,
+
+ [property:JsonProperty("version")]
+ string Version = "1.0.0");
diff --git a/src/SharpIDE.Application/Features/Testing/Client/Dtos/DiscoverRequest.cs b/src/SharpIDE.Application/Features/Testing/Client/Dtos/DiscoverRequest.cs
new file mode 100644
index 0000000..d35948f
--- /dev/null
+++ b/src/SharpIDE.Application/Features/Testing/Client/Dtos/DiscoverRequest.cs
@@ -0,0 +1,9 @@
+
+
+using Newtonsoft.Json;
+
+namespace SharpIDE.Application.Features.Testing.Client.Dtos;
+
+public sealed record DiscoveryRequest(
+ [property:JsonProperty("runId")]
+ Guid RunId);
diff --git a/src/SharpIDE.Application/Features/Testing/Client/Dtos/InitializeRequest.cs b/src/SharpIDE.Application/Features/Testing/Client/Dtos/InitializeRequest.cs
new file mode 100644
index 0000000..7d4f3e1
--- /dev/null
+++ b/src/SharpIDE.Application/Features/Testing/Client/Dtos/InitializeRequest.cs
@@ -0,0 +1,15 @@
+
+
+using Newtonsoft.Json;
+
+namespace SharpIDE.Application.Features.Testing.Client.Dtos;
+
+public sealed record InitializeRequest(
+ [property:JsonProperty("processId")]
+ int ProcessId,
+
+ [property:JsonProperty("clientInfo")]
+ ClientInfo ClientInfo,
+
+ [property:JsonProperty("capabilities")]
+ ClientCapabilities Capabilities);
diff --git a/src/SharpIDE.Application/Features/Testing/Client/Dtos/InitializeResponse.cs b/src/SharpIDE.Application/Features/Testing/Client/Dtos/InitializeResponse.cs
new file mode 100644
index 0000000..0e38670
--- /dev/null
+++ b/src/SharpIDE.Application/Features/Testing/Client/Dtos/InitializeResponse.cs
@@ -0,0 +1,7 @@
+
+
+namespace SharpIDE.Application.Features.Testing.Client.Dtos;
+
+public sealed record InitializeResponse(
+ ServerInfo ServerInfo,
+ ServerCapabilities Capabilities);
diff --git a/src/SharpIDE.Application/Features/Testing/Client/Dtos/LogLevel.cs b/src/SharpIDE.Application/Features/Testing/Client/Dtos/LogLevel.cs
new file mode 100644
index 0000000..c9d964e
--- /dev/null
+++ b/src/SharpIDE.Application/Features/Testing/Client/Dtos/LogLevel.cs
@@ -0,0 +1,44 @@
+
+
+namespace SharpIDE.Application.Features.Testing.Client.Dtos;
+
+///
+/// Log level.
+///
+public enum LogLevel
+{
+ ///
+ /// Trace.
+ ///
+ Trace = 0,
+
+ ///
+ /// Debug.
+ ///
+ Debug = 1,
+
+ ///
+ /// Information.
+ ///
+ Information = 2,
+
+ ///
+ /// Warning.
+ ///
+ Warning = 3,
+
+ ///
+ /// Error.
+ ///
+ Error = 4,
+
+ ///
+ /// Critical.
+ ///
+ Critical = 5,
+
+ ///
+ /// None.
+ ///
+ None = 6,
+}
diff --git a/src/SharpIDE.Application/Features/Testing/Client/Dtos/RpcListener.cs b/src/SharpIDE.Application/Features/Testing/Client/Dtos/RpcListener.cs
new file mode 100644
index 0000000..caf4f0d
--- /dev/null
+++ b/src/SharpIDE.Application/Features/Testing/Client/Dtos/RpcListener.cs
@@ -0,0 +1,12 @@
+
+
+using System.Diagnostics;
+
+namespace SharpIDE.Application.Features.Testing.Client.Dtos;
+
+internal sealed class ConsoleRpcListener : TraceListener
+{
+ public override void Write(string? message) => Console.Write(message ?? string.Empty);
+
+ public override void WriteLine(string? message) => Console.WriteLine(message ?? string.Empty);
+}
diff --git a/src/SharpIDE.Application/Features/Testing/Client/Dtos/RunRequest.cs b/src/SharpIDE.Application/Features/Testing/Client/Dtos/RunRequest.cs
new file mode 100644
index 0000000..f285643
--- /dev/null
+++ b/src/SharpIDE.Application/Features/Testing/Client/Dtos/RunRequest.cs
@@ -0,0 +1,11 @@
+
+
+using Newtonsoft.Json;
+
+namespace SharpIDE.Application.Features.Testing.Client.Dtos;
+
+public sealed record RunRequest(
+ [property:JsonProperty("tests")]
+ TestNode[]? TestCases,
+ [property:JsonProperty("runId")]
+ Guid RunId);
diff --git a/src/SharpIDE.Application/Features/Testing/Client/Dtos/ServerCapabilities.cs b/src/SharpIDE.Application/Features/Testing/Client/Dtos/ServerCapabilities.cs
new file mode 100644
index 0000000..05ab76d
--- /dev/null
+++ b/src/SharpIDE.Application/Features/Testing/Client/Dtos/ServerCapabilities.cs
@@ -0,0 +1,17 @@
+
+
+using Newtonsoft.Json;
+
+namespace SharpIDE.Application.Features.Testing.Client.Dtos;
+
+public sealed record ServerCapabilities(
+ [property: JsonProperty("testing")]
+ ServerTestingCapabilities Testing);
+
+public sealed record ServerTestingCapabilities(
+ [property: JsonProperty("supportsDiscovery")]
+ bool SupportsDiscovery,
+ [property: JsonProperty("experimental_multiRequestSupport")]
+ bool MultiRequestSupport,
+ [property: JsonProperty("vsTestProvider")]
+ bool VSTestProvider);
diff --git a/src/SharpIDE.Application/Features/Testing/Client/Dtos/ServerInfo.cs b/src/SharpIDE.Application/Features/Testing/Client/Dtos/ServerInfo.cs
new file mode 100644
index 0000000..45e8531
--- /dev/null
+++ b/src/SharpIDE.Application/Features/Testing/Client/Dtos/ServerInfo.cs
@@ -0,0 +1,12 @@
+
+
+using Newtonsoft.Json;
+
+namespace SharpIDE.Application.Features.Testing.Client.Dtos;
+
+public sealed record ServerInfo(
+ [property:JsonProperty("name")]
+ string Name,
+
+ [property:JsonProperty("version")]
+ string Version = "1.0.0");
diff --git a/src/SharpIDE.Application/Features/Testing/Client/Dtos/TelemetryPayload.cs b/src/SharpIDE.Application/Features/Testing/Client/Dtos/TelemetryPayload.cs
new file mode 100644
index 0000000..4a97ba5
--- /dev/null
+++ b/src/SharpIDE.Application/Features/Testing/Client/Dtos/TelemetryPayload.cs
@@ -0,0 +1,13 @@
+
+
+using Newtonsoft.Json;
+
+namespace SharpIDE.Application.Features.Testing.Client.Dtos;
+
+public record TelemetryPayload
+(
+ [property: JsonProperty(nameof(TelemetryPayload.EventName))]
+ string EventName,
+
+ [property: JsonProperty("metrics")]
+ IDictionary Metrics);
diff --git a/src/SharpIDE.Application/Features/Testing/Client/Dtos/TestNode.cs b/src/SharpIDE.Application/Features/Testing/Client/Dtos/TestNode.cs
new file mode 100644
index 0000000..da25a4c
--- /dev/null
+++ b/src/SharpIDE.Application/Features/Testing/Client/Dtos/TestNode.cs
@@ -0,0 +1,27 @@
+
+
+using Newtonsoft.Json;
+
+namespace SharpIDE.Application.Features.Testing.Client.Dtos;
+
+public sealed record TestNodeUpdate
+(
+ [property: JsonProperty("node")]
+ TestNode Node,
+
+ [property: JsonProperty("parent")]
+ string ParentUid);
+
+public sealed record TestNode
+(
+ [property: JsonProperty("uid")]
+ string Uid,
+
+ [property: JsonProperty("display-name")]
+ string DisplayName,
+
+ [property: JsonProperty("node-type")]
+ string NodeType,
+
+ [property: JsonProperty("execution-state")]
+ string ExecutionState);
diff --git a/src/SharpIDE.Application/Features/Testing/Client/ExecutionStates.cs b/src/SharpIDE.Application/Features/Testing/Client/ExecutionStates.cs
new file mode 100644
index 0000000..fcfe2e9
--- /dev/null
+++ b/src/SharpIDE.Application/Features/Testing/Client/ExecutionStates.cs
@@ -0,0 +1,13 @@
+namespace SharpIDE.Application.Features.Testing.Client;
+
+public class ExecutionStates
+{
+ public const string Passed = "passed";
+ public const string Discovered = "discovered";
+ public const string Failed = "failed";
+ public const string Skipped = "skipped";
+ public const string TimedOut = "timed-out";
+ public const string Error = "error";
+ public const string Cancelled = "cancelled";
+ public const string InProgress = "in-progress";
+}
diff --git a/src/SharpIDE.Application/Features/Testing/Client/LogsCollector.cs b/src/SharpIDE.Application/Features/Testing/Client/LogsCollector.cs
new file mode 100644
index 0000000..592a14d
--- /dev/null
+++ b/src/SharpIDE.Application/Features/Testing/Client/LogsCollector.cs
@@ -0,0 +1,5 @@
+using System.Collections.Concurrent;
+
+namespace SharpIDE.Application.Features.Testing.Client;
+
+public class LogsCollector : ConcurrentBag;
diff --git a/src/SharpIDE.Application/Features/Testing/Client/RootFinder.cs b/src/SharpIDE.Application/Features/Testing/Client/RootFinder.cs
new file mode 100644
index 0000000..b4a4b74
--- /dev/null
+++ b/src/SharpIDE.Application/Features/Testing/Client/RootFinder.cs
@@ -0,0 +1,54 @@
+
+
+namespace SharpIDE.Application.Features.Testing.Client;
+
+///
+/// Provides functionality to locate the root directory of a Git repository.
+///
+/// The class is used to find the root directory of a Git repository by
+/// searching for a ".git" directory or file starting from the application's base directory and moving up the directory
+/// hierarchy. This is useful for applications that need to determine the root of a project or repository.
+#if ROOT_FINDER_PUBLIC
+public
+#else
+internal
+#endif
+ static class RootFinder
+{
+ private static string? s_root;
+
+ ///
+ /// Finds the root directory of a Git repository starting from the application's base directory.
+ ///
+ /// This method searches for a ".git" directory or file in the application's base directory and
+ /// its parent directories. If a Git repository is found, the path to its root directory is returned. If no Git
+ /// repository is found, an is thrown.
+ /// The path to the root directory of the Git repository, ending with a directory separator character.
+ /// Thrown if a Git repository is not found in the application's base directory or any of its parent directories.
+ public static string Find()
+ {
+ if (s_root != null)
+ {
+ return s_root;
+ }
+
+ string path = AppContext.BaseDirectory;
+ string currentDirectory = path;
+ string rootDriveDirectory = Directory.GetDirectoryRoot(currentDirectory);
+ while (rootDriveDirectory != currentDirectory)
+ {
+ string gitPath = Path.Combine(currentDirectory, ".git");
+
+ // When working with git worktrees, the .git is a file not a folder
+ if (Directory.Exists(gitPath) || File.Exists(gitPath))
+ {
+ s_root = currentDirectory + Path.DirectorySeparatorChar;
+ return s_root;
+ }
+
+ currentDirectory = Directory.GetParent(currentDirectory)!.ToString();
+ }
+
+ throw new InvalidOperationException($"Could not find solution root, .git not found in {path} or any parent directory.");
+ }
+}
diff --git a/src/SharpIDE.Application/Features/Testing/Client/TelemetryCollector.cs b/src/SharpIDE.Application/Features/Testing/Client/TelemetryCollector.cs
new file mode 100644
index 0000000..c190783
--- /dev/null
+++ b/src/SharpIDE.Application/Features/Testing/Client/TelemetryCollector.cs
@@ -0,0 +1,6 @@
+using System.Collections.Concurrent;
+using SharpIDE.Application.Features.Testing.Client.Dtos;
+
+namespace SharpIDE.Application.Features.Testing.Client;
+
+public class TelemetryCollector : ConcurrentBag;
diff --git a/src/SharpIDE.Application/Features/Testing/Client/TestingPlatformClient.cs b/src/SharpIDE.Application/Features/Testing/Client/TestingPlatformClient.cs
new file mode 100644
index 0000000..83bebd9
--- /dev/null
+++ b/src/SharpIDE.Application/Features/Testing/Client/TestingPlatformClient.cs
@@ -0,0 +1,249 @@
+using System.Collections.Concurrent;
+using System.Diagnostics;
+using System.Net.Sockets;
+using System.Text;
+using SharpIDE.Application.Features.Testing.Client.Dtos;
+using StreamJsonRpc;
+using AttachDebuggerInfo = SharpIDE.Application.Features.Testing.Client.Dtos.AttachDebuggerInfo;
+using ClientCapabilities = SharpIDE.Application.Features.Testing.Client.Dtos.ClientCapabilities;
+using ClientInfo = SharpIDE.Application.Features.Testing.Client.Dtos.ClientInfo;
+
+namespace SharpIDE.Application.Features.Testing.Client;
+
+public sealed class TestingPlatformClient : IDisposable
+{
+ private readonly TcpClient _tcpClient = new();
+ private readonly IProcessHandle _processHandler;
+ private readonly TargetHandler _targetHandler = new();
+ private readonly StringBuilder _disconnectionReason = new();
+
+ public TestingPlatformClient(JsonRpc jsonRpc, TcpClient tcpClient, IProcessHandle processHandler, bool enableDiagnostic = false)
+ {
+ JsonRpcClient = jsonRpc;
+ _tcpClient = tcpClient;
+ _processHandler = processHandler;
+ JsonRpcClient.AddLocalRpcTarget(
+ _targetHandler,
+ new JsonRpcTargetOptions
+ {
+ MethodNameTransform = CommonMethodNameTransforms.CamelCase,
+ });
+
+ if (enableDiagnostic)
+ {
+ JsonRpcClient.TraceSource.Switch.Level = SourceLevels.All;
+ JsonRpcClient.TraceSource.Listeners.Add(new ConsoleRpcListener());
+ }
+
+ JsonRpcClient.Disconnected += JsonRpcClient_Disconnected;
+ JsonRpcClient.StartListening();
+ }
+
+ private void JsonRpcClient_Disconnected(object? sender, JsonRpcDisconnectedEventArgs e)
+ {
+ _disconnectionReason.AppendLine("Disconnected reason:");
+ _disconnectionReason.AppendLine(e.Reason.ToString());
+ _disconnectionReason.AppendLine(e.Description);
+ _disconnectionReason.AppendLine(e.Exception?.ToString());
+ }
+
+ public int ExitCode => _processHandler.ExitCode;
+
+ public async Task WaitServerProcessExitAsync()
+ {
+ await _processHandler.WaitForExitAsync();
+ return _processHandler.ExitCode;
+ }
+
+ public JsonRpc JsonRpcClient { get; }
+
+ private async Task CheckedInvokeAsync(Func func)
+ {
+ try
+ {
+ await func();
+ }
+ catch (Exception ex) when (_disconnectionReason.Length > 0)
+ {
+ throw new InvalidOperationException($"{ex.Message}\n{_disconnectionReason}", ex);
+ }
+ }
+
+ private async Task CheckedInvokeAsync(Func> func, bool @checked = true)
+ {
+ try
+ {
+ return await func();
+ }
+ catch (Exception ex)
+ {
+ if (@checked)
+ {
+ if (_disconnectionReason.Length > 0)
+ {
+ throw new InvalidOperationException($"{ex.Message}\n{_disconnectionReason}", ex);
+ }
+
+ throw;
+ }
+ }
+
+ return default!;
+ }
+
+ public void RegisterLogListener(LogsCollector listener)
+ => _targetHandler.RegisterLogListener(listener);
+
+ public void RegisterTelemetryListener(TelemetryCollector listener)
+ => _targetHandler.RegisterTelemetryListener(listener);
+
+ public async Task InitializeAsync()
+ {
+ using CancellationTokenSource cancellationTokenSource = new(TimeSpan.FromMinutes(3));
+ return await CheckedInvokeAsync(async () => await JsonRpcClient.InvokeWithParameterObjectAsync(
+ "initialize",
+ new InitializeRequest(Environment.ProcessId, new ClientInfo("test-client"),
+ new ClientCapabilities(new ClientTestingCapabilities(DebuggerProvider: false))), cancellationToken: cancellationTokenSource.Token));
+ }
+
+ public async Task ExitAsync(bool gracefully = true)
+ {
+ if (gracefully)
+ {
+ using CancellationTokenSource cancellationTokenSource = new(TimeSpan.FromMinutes(3));
+ await CheckedInvokeAsync(async () => await JsonRpcClient.NotifyWithParameterObjectAsync("exit", new object()));
+ }
+ else
+ {
+ _tcpClient.Dispose();
+ }
+ }
+
+ public async Task DiscoverTestsAsync(Guid requestId, Func action, bool @checked = true)
+ => await CheckedInvokeAsync(
+ async () =>
+ {
+ using CancellationTokenSource cancellationTokenSource = new(TimeSpan.FromMinutes(3));
+ var discoveryListener = new TestNodeUpdatesResponseListener(requestId, action);
+ _targetHandler.RegisterResponseListener(discoveryListener);
+ await JsonRpcClient.InvokeWithParameterObjectAsync("testing/discoverTests", new DiscoveryRequest(RunId: requestId), cancellationToken: cancellationTokenSource.Token);
+ return discoveryListener;
+ }, @checked);
+
+ public async Task RunTestsAsync(Guid requestId, Func action)
+ => await CheckedInvokeAsync(async () =>
+ {
+ using CancellationTokenSource cancellationTokenSource = new(TimeSpan.FromMinutes(3));
+ var runListener = new TestNodeUpdatesResponseListener(requestId, action);
+ _targetHandler.RegisterResponseListener(runListener);
+ await JsonRpcClient.InvokeWithParameterObjectAsync("testing/runTests", new RunRequest(RunId: requestId, TestCases: null), cancellationToken: cancellationTokenSource.Token);
+ return runListener;
+ });
+
+ public async Task RunTestsAsync(Guid requestId, TestNode[] filter, Func action)
+ => await CheckedInvokeAsync(async () =>
+ {
+ using CancellationTokenSource cancellationTokenSource = new(TimeSpan.FromMinutes(3));
+ var runListener = new TestNodeUpdatesResponseListener(requestId, action);
+ _targetHandler.RegisterResponseListener(runListener);
+ await JsonRpcClient.InvokeWithParameterObjectAsync("testing/runTests", new RunRequest(TestCases: filter, RunId: requestId), cancellationToken: cancellationTokenSource.Token);
+ return runListener;
+ });
+
+ public void Dispose()
+ {
+ JsonRpcClient.Dispose();
+ _tcpClient.Dispose();
+ _processHandler.WaitForExit();
+ _processHandler.Dispose();
+ }
+
+ public record Log(LogLevel LogLevel, string Message);
+
+ private sealed class TargetHandler
+ {
+ private readonly ConcurrentDictionary _listeners
+ = new();
+
+ private readonly ConcurrentBag _logListeners = [];
+
+ private readonly ConcurrentBag _telemetryPayloads = [];
+
+ public void RegisterTelemetryListener(TelemetryCollector listener)
+ => _telemetryPayloads.Add(listener);
+
+ public void RegisterLogListener(LogsCollector listener)
+ => _logListeners.Add(listener);
+
+ public void RegisterResponseListener(ResponseListener responseListener)
+ => _ = _listeners.TryAdd(responseListener.RequestId, responseListener);
+
+ [JsonRpcMethod("client/attachDebugger", UseSingleObjectParameterDeserialization = true)]
+ public static Task AttachDebuggerAsync(AttachDebuggerInfo attachDebuggerInfo) => throw new NotImplementedException();
+
+ [JsonRpcMethod("testing/testUpdates/tests")]
+ public async Task TestsUpdateAsync(Guid runId, TestNodeUpdate[]? changes)
+ {
+ if (_listeners.TryGetValue(runId, out ResponseListener? responseListener))
+ {
+ if (changes is null)
+ {
+ responseListener.Complete();
+ _listeners.TryRemove(runId, out _);
+ }
+ else
+ {
+ await responseListener.OnMessageReceiveAsync(changes);
+ }
+ }
+ }
+
+ [JsonRpcMethod("telemetry/update", UseSingleObjectParameterDeserialization = true)]
+ public Task TelemetryAsync(TelemetryPayload telemetry)
+ {
+ foreach (TelemetryCollector listener in _telemetryPayloads)
+ {
+ listener.Add(telemetry);
+ }
+
+ return Task.CompletedTask;
+ }
+
+ [JsonRpcMethod("client/log")]
+ public Task LogAsync(LogLevel level, string message)
+ {
+ foreach (LogsCollector listener in _logListeners)
+ {
+ listener.Add(new(level, message));
+ }
+
+ return Task.CompletedTask;
+ }
+ }
+}
+
+public abstract class ResponseListener
+{
+ private readonly TaskCompletionSource _allMessageReceived = new();
+
+ public Guid RequestId { get; set; }
+
+ protected ResponseListener(Guid requestId) => RequestId = requestId;
+
+ public abstract Task OnMessageReceiveAsync(object message);
+
+ internal void Complete() => _allMessageReceived.SetResult();
+
+ public Task WaitCompletionAsync() => _allMessageReceived.Task;
+}
+
+public sealed class TestNodeUpdatesResponseListener : ResponseListener
+{
+ private readonly Func _action;
+
+ public TestNodeUpdatesResponseListener(Guid requestId, Func action)
+ : base(requestId) => _action = action;
+
+ public override async Task OnMessageReceiveAsync(object message)
+ => await _action((TestNodeUpdate[])message);
+}
diff --git a/src/SharpIDE.Application/Features/Testing/Client/TestingPlatformClientFactory.cs b/src/SharpIDE.Application/Features/Testing/Client/TestingPlatformClientFactory.cs
new file mode 100644
index 0000000..f5c9ae2
--- /dev/null
+++ b/src/SharpIDE.Application/Features/Testing/Client/TestingPlatformClientFactory.cs
@@ -0,0 +1,373 @@
+using System.Collections;
+using System.Diagnostics;
+using System.Globalization;
+using System.Net;
+using System.Net.Sockets;
+using System.Text;
+
+namespace SharpIDE.Application.Features.Testing.Client;
+
+public partial /* for codegen regx */ class TestingPlatformClientFactory
+{
+ private static readonly string Root = RootFinder.Find();
+ private static readonly Dictionary DefaultEnvironmentVariables = new()
+ {
+ { "DOTNET_ROOT", $"{Root}/.dotnet" },
+ { "DOTNET_INSTALL_DIR", $"{Root}/.dotnet" },
+ { "DOTNET_SKIP_FIRST_TIME_EXPERIENCE", "1" },
+ { "DOTNET_MULTILEVEL_LOOKUP", "0" },
+ };
+
+ public static async Task StartAsServerAndConnectToTheClientAsync(string testApp)
+ {
+ var environmentVariables = new Dictionary(DefaultEnvironmentVariables);
+ foreach (DictionaryEntry entry in Environment.GetEnvironmentVariables())
+ {
+ // Skip all unwanted environment variables.
+ string? key = entry.Key.ToString();
+ if (WellKnownEnvironmentVariables.ToSkipEnvironmentVariables.Contains(key, StringComparer.OrdinalIgnoreCase))
+ {
+ continue;
+ }
+
+ environmentVariables[key!] = entry.Value!.ToString()!;
+ }
+
+ // We expect to not fail for unhandled exception in server mode for IDE needs.
+ environmentVariables.Add("TESTINGPLATFORM_EXIT_PROCESS_ON_UNHANDLED_EXCEPTION", "0");
+
+ // To attach to the server on startup
+ // environmentVariables.Add(EnvironmentVariableConstants.TESTINGPLATFORM_LAUNCH_ATTACH_DEBUGGER, "1");
+ TcpListener tcpListener = new(IPAddress.Loopback, 0);
+ tcpListener.Start();
+ StringBuilder builder = new();
+ ProcessConfiguration processConfig = new(testApp)
+ {
+ OnStandardOutput = (_, output) => builder.AppendLine(CultureInfo.InvariantCulture, $"OnStandardOutput:\n{output}"),
+ OnErrorOutput = (_, output) => builder.AppendLine(CultureInfo.InvariantCulture, $"OnErrorOutput:\n{output}"),
+ OnExit = (_, exitCode) => builder.AppendLine(CultureInfo.InvariantCulture, $"OnExit: exit code '{exitCode}'"),
+
+ Arguments = $"--server --client-host localhost --client-port {((IPEndPoint)tcpListener.LocalEndpoint).Port}",
+ // Arguments = $"--server --client-host localhost --client-port {((IPEndPoint)tcpListener.LocalEndpoint).Port} --diagnostic --diagnostic-verbosity trace",
+ EnvironmentVariables = environmentVariables,
+ };
+
+ IProcessHandle processHandler = ProcessFactory.Start(processConfig, cleanDefaultEnvironmentVariableIfCustomAreProvided: false);
+
+ TcpClient? tcpClient;
+ using CancellationTokenSource cancellationTokenSource = new(TimeSpan.FromSeconds(60));
+ try
+ {
+ tcpClient = await tcpListener.AcceptTcpClientAsync(cancellationTokenSource.Token);
+ }
+ catch (OperationCanceledException ex) when (ex.CancellationToken == cancellationTokenSource.Token)
+ {
+ throw new OperationCanceledException($"Timeout on connection for command line '{processConfig.FileName} {processConfig.Arguments}'\n{builder}", ex, cancellationTokenSource.Token);
+ }
+
+ return new TestingPlatformClient(new(tcpClient.GetStream()), tcpClient, processHandler);
+ }
+}
+
+public sealed class ProcessConfiguration
+{
+ public ProcessConfiguration(string fileName) => FileName = fileName;
+
+ public string FileName { get; }
+
+ public string? Arguments { get; init; }
+
+ public string? WorkingDirectory { get; init; }
+
+ public IDictionary? EnvironmentVariables { get; init; }
+
+ public Action? OnErrorOutput { get; init; }
+
+ public Action? OnStandardOutput { get; init; }
+
+ public Action? OnExit { get; init; }
+}
+
+public interface IProcessHandle
+{
+ int Id { get; }
+ string ProcessName { get; }
+ int ExitCode { get; }
+ TextWriter StandardInput { get; }
+ TextReader StandardOutput { get; }
+ void Dispose();
+ void Kill();
+ Task StopAsync();
+ Task WaitForExitAsync();
+ void WaitForExit();
+ Task WriteInputAsync(string input);
+}
+
+public static class ProcessFactory
+{
+ public static IProcessHandle Start(ProcessConfiguration config, bool cleanDefaultEnvironmentVariableIfCustomAreProvided = false)
+ {
+ string fullPath = config.FileName; // Path.GetFullPath(startInfo.FileName);
+ string workingDirectory = config.WorkingDirectory
+ .OrDefault(Path.GetDirectoryName(config.FileName).OrDefault(Directory.GetCurrentDirectory()));
+
+ ProcessStartInfo processStartInfo = new()
+ {
+ FileName = fullPath,
+ Arguments = config.Arguments,
+ WorkingDirectory = workingDirectory,
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ RedirectStandardInput = true,
+ };
+
+ if (config.EnvironmentVariables is not null)
+ {
+ if (cleanDefaultEnvironmentVariableIfCustomAreProvided)
+ {
+ processStartInfo.Environment.Clear();
+ processStartInfo.EnvironmentVariables.Clear();
+ }
+
+ foreach (KeyValuePair kvp in config.EnvironmentVariables)
+ {
+ if (kvp.Value is null)
+ {
+ continue;
+ }
+
+ processStartInfo.EnvironmentVariables[kvp.Key] = kvp.Value;
+ }
+ }
+
+ Process process = new()
+ {
+ StartInfo = processStartInfo,
+ EnableRaisingEvents = true,
+ };
+
+ // ToolName and Pid are not populated until we start the process,
+ // and once we stop the process we cannot retrieve the info anymore
+ // so we start the process, try to grab the needed info and set it.
+ // And then we give the call reference to ProcessHandle, but not to ProcessHandleInfo
+ // so they can easily get the info, but cannot change it.
+ ProcessHandleInfo processHandleInfo = new();
+ ProcessHandle processHandle = new(process, processHandleInfo);
+
+ if (config.OnExit != null)
+ {
+ process.Exited += (_, _) => config.OnExit.Invoke(processHandle, process.ExitCode);
+ }
+
+ if (config.OnStandardOutput != null)
+ {
+ process.OutputDataReceived += (s, e) =>
+ {
+ if (!string.IsNullOrWhiteSpace(e.Data))
+ {
+ config.OnStandardOutput(processHandle, e.Data);
+ }
+ };
+ }
+
+ if (config.OnErrorOutput != null)
+ {
+ process.ErrorDataReceived += (s, e) =>
+ {
+ if (!string.IsNullOrWhiteSpace(e.Data))
+ {
+ config.OnErrorOutput(processHandle, e.Data);
+ }
+ };
+ }
+
+ if (!process.Start())
+ {
+ throw new InvalidOperationException("Process failed to start");
+ }
+
+ try
+ {
+ processHandleInfo.ProcessName = process.ProcessName;
+ }
+ catch (InvalidOperationException)
+ {
+ // The associated process has exited.
+ // https://learn.microsoft.com/dotnet/api/system.diagnostics.process.processname?view=net-7.0
+ }
+
+ processHandleInfo.Id = process.Id;
+
+ if (config.OnStandardOutput != null)
+ {
+ process.BeginOutputReadLine();
+ }
+
+ if (config.OnErrorOutput != null)
+ {
+ process.BeginErrorReadLine();
+ }
+
+ return processHandle;
+ }
+}
+
+public sealed class ProcessHandleInfo
+{
+ public string? ProcessName { get; internal set; }
+
+ public int Id { get; internal set; }
+}
+
+public sealed class ProcessHandle : IProcessHandle, IDisposable
+{
+ private readonly ProcessHandleInfo _processHandleInfo;
+ private readonly Process _process;
+ private bool _disposed;
+ private int _exitCode;
+
+ internal ProcessHandle(Process process, ProcessHandleInfo processHandleInfo)
+ {
+ _processHandleInfo = processHandleInfo;
+ _process = process;
+ }
+
+ public string ProcessName => _processHandleInfo.ProcessName ?? "";
+
+ public int Id => _processHandleInfo.Id;
+
+ public TextWriter StandardInput => _process.StandardInput;
+
+ public TextReader StandardOutput => _process.StandardOutput;
+
+ public int ExitCode => _process.ExitCode;
+
+ public async Task WaitForExitAsync()
+ {
+ if (!_disposed)
+ {
+ await _process.WaitForExitAsync();
+ }
+
+ return _exitCode;
+ }
+
+ public void WaitForExit() => _process.WaitForExit();
+
+ public async Task StopAsync()
+ {
+ if (_disposed)
+ {
+ return _exitCode;
+ }
+
+ KillSafe(_process);
+ return await WaitForExitAsync();
+ }
+
+ public void Kill()
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ KillSafe(_process);
+ }
+
+ public void Dispose()
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ lock (_process)
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ _disposed = true;
+ }
+
+ KillSafe(_process);
+ _process.WaitForExit();
+ _exitCode = _process.ExitCode;
+ _process.Dispose();
+ }
+
+ public async Task WriteInputAsync(string input)
+ {
+ await _process.StandardInput.WriteLineAsync(input);
+ await _process.StandardInput.FlushAsync();
+ }
+
+ private static void KillSafe(Process process)
+ {
+ try
+ {
+ process.Kill(true);
+ }
+ catch (InvalidOperationException)
+ {
+ }
+ catch (NotSupportedException)
+ {
+ }
+ }
+}
+
+public static class StringExtensions
+{
+ // Double checking that is is not null on purpose.
+ public static string OrDefault(this string? value, string defaultValue) => string.IsNullOrEmpty(defaultValue)
+ ? throw new ArgumentNullException(nameof(defaultValue))
+ : !string.IsNullOrWhiteSpace(value)
+ ? value!
+ : defaultValue;
+}
+
+public static class WellKnownEnvironmentVariables
+{
+ public static readonly string[] ToSkipEnvironmentVariables =
+ [
+ // Skip dotnet root, we redefine it below.
+ "DOTNET_ROOT",
+
+ // Skip all environment variables related to minidump functionality.
+ // https://github.com/dotnet/runtime/blob/main/docs/design/coreclr/botr/xplat-minidump-generation.md
+ "DOTNET_DbgEnableMiniDump",
+ "DOTNET_DbgMiniDumpName",
+ "DOTNET_CreateDumpDiagnostics",
+ "DOTNET_CreateDumpVerboseDiagnostics",
+ "DOTNET_CreateDumpLogToFile",
+ "DOTNET_EnableCrashReport",
+ "DOTNET_EnableCrashReportOnly",
+
+ // Old syntax for the minidump functionality.
+ "COMPlus_DbgEnableMiniDump",
+ "COMPlus_DbgEnableElfDumpOnMacOS",
+ "COMPlus_DbgMiniDumpName",
+ "COMPlus_DbgMiniDumpType",
+
+ // Hot reload mode
+ "TESTINGPLATFORM_HOTRELOAD_ENABLED",
+
+ // Telemetry
+ // By default arcade set this environment variable
+ "DOTNET_CLI_TELEMETRY_OPTOUT",
+ "TESTINGPLATFORM_TELEMETRY_OPTOUT",
+ "DOTNET_NOLOGO",
+ "TESTINGPLATFORM_NOBANNER",
+
+ // Diagnostics
+ "TESTINGPLATFORM_DIAGNOSTIC",
+
+ // Isolate from the skip banner in case of parent, children tests
+ "TESTINGPLATFORM_CONSOLEOUTPUTDEVICE_SKIP_BANNER"
+ ];
+}
diff --git a/src/SharpIDE.Application/Features/Testing/TestRunnerService.cs b/src/SharpIDE.Application/Features/Testing/TestRunnerService.cs
new file mode 100644
index 0000000..30b6144
--- /dev/null
+++ b/src/SharpIDE.Application/Features/Testing/TestRunnerService.cs
@@ -0,0 +1,53 @@
+using SharpIDE.Application.Features.Evaluation;
+using SharpIDE.Application.Features.SolutionDiscovery.VsPersistence;
+using SharpIDE.Application.Features.Testing.Client;
+using SharpIDE.Application.Features.Testing.Client.Dtos;
+
+namespace SharpIDE.Application.Features.Testing;
+
+public class TestRunnerService
+{
+ // Assumes it has already been built
+ public async Task RunTestsAsync(SharpIdeProjectModel project)
+ {
+ // get path to executable
+ var outputDllPath = ProjectEvaluation.GetOutputDllFullPath(project);
+ var outputExecutablePath = 0 switch
+ {
+ _ when OperatingSystem.IsWindows() => outputDllPath!.Replace(".dll", ".exe"),
+ _ when OperatingSystem.IsLinux() => outputDllPath!.Replace(".dll", ""),
+ _ when OperatingSystem.IsMacOS() => outputDllPath!.Replace(".dll", ""),
+ _ => throw new PlatformNotSupportedException("Unsupported OS for running tests.")
+ };
+
+ using var client = await TestingPlatformClientFactory.StartAsServerAndConnectToTheClientAsync(outputExecutablePath);
+
+ await client.InitializeAsync();
+ List testNodeUpdates = [];
+ var discoveryResponse = await client.DiscoverTestsAsync(Guid.NewGuid(), node =>
+ {
+ testNodeUpdates.AddRange(node);
+ return Task.CompletedTask;
+ });
+ await discoveryResponse.WaitCompletionAsync();
+
+ Console.WriteLine($"Discovery finished: {testNodeUpdates.Count} tests discovered");
+ Console.WriteLine(string.Join(Environment.NewLine, testNodeUpdates.Select(n => n.Node.DisplayName)));
+
+ List runResults = [];
+ ResponseListener runRequest = await client.RunTestsAsync(Guid.NewGuid(), testNodeUpdates.Select(x => x.Node).ToArray(), node =>
+ {
+ runResults.AddRange(node);
+ return Task.CompletedTask;
+ });
+ await runRequest.WaitCompletionAsync();
+
+
+ var passedCount = runResults.Where(tn => tn.Node.ExecutionState == ExecutionStates.Passed).Count();
+ var failedCount = runResults.Where(tn => tn.Node.ExecutionState == ExecutionStates.Failed).Count();
+ var skippedCount = runResults.Where(tn => tn.Node.ExecutionState == ExecutionStates.Skipped).Count();
+
+ Console.WriteLine($"Passed: {passedCount}; Skipped: {skippedCount}; Failed: {failedCount};");
+ await client.ExitAsync();
+ }
+}
diff --git a/src/SharpIDE.Godot/DiAutoload.cs b/src/SharpIDE.Godot/DiAutoload.cs
index 0f4dd1c..6229e8b 100644
--- a/src/SharpIDE.Godot/DiAutoload.cs
+++ b/src/SharpIDE.Godot/DiAutoload.cs
@@ -12,6 +12,7 @@ using SharpIDE.Application.Features.NavigationHistory;
using SharpIDE.Application.Features.Nuget;
using SharpIDE.Application.Features.Run;
using SharpIDE.Application.Features.Search;
+using SharpIDE.Application.Features.Testing;
namespace SharpIDE.Godot;
@@ -38,6 +39,7 @@ public partial class DiAutoload : Node
services.AddScoped();
services.AddScoped();
services.AddScoped();
+ services.AddScoped();
services.AddScoped();
services.AddScoped();
services.AddScoped();