From b718b2c4e1998954b6a79dae20f92da317271ba1 Mon Sep 17 00:00:00 2001 From: Matt Parker <61717342+MattParkerDev@users.noreply.github.com> Date: Tue, 4 Nov 2025 00:47:23 +1000 Subject: [PATCH] test runner v1 --- .../Testing/Client/Dtos/AttachDebuggerInfo.cs | 7 + .../Testing/Client/Dtos/ClientCapabilities.cs | 13 + .../Testing/Client/Dtos/ClientInfo.cs | 12 + .../Testing/Client/Dtos/DiscoverRequest.cs | 9 + .../Testing/Client/Dtos/InitializeRequest.cs | 15 + .../Testing/Client/Dtos/InitializeResponse.cs | 7 + .../Features/Testing/Client/Dtos/LogLevel.cs | 44 +++ .../Testing/Client/Dtos/RpcListener.cs | 12 + .../Testing/Client/Dtos/RunRequest.cs | 11 + .../Testing/Client/Dtos/ServerCapabilities.cs | 17 + .../Testing/Client/Dtos/ServerInfo.cs | 12 + .../Testing/Client/Dtos/TelemetryPayload.cs | 13 + .../Features/Testing/Client/Dtos/TestNode.cs | 27 ++ .../Testing/Client/ExecutionStates.cs | 13 + .../Features/Testing/Client/LogsCollector.cs | 5 + .../Features/Testing/Client/RootFinder.cs | 54 +++ .../Testing/Client/TelemetryCollector.cs | 6 + .../Testing/Client/TestingPlatformClient.cs | 249 ++++++++++++ .../Client/TestingPlatformClientFactory.cs | 373 ++++++++++++++++++ .../Features/Testing/TestRunnerService.cs | 53 +++ src/SharpIDE.Godot/DiAutoload.cs | 2 + 21 files changed, 954 insertions(+) create mode 100644 src/SharpIDE.Application/Features/Testing/Client/Dtos/AttachDebuggerInfo.cs create mode 100644 src/SharpIDE.Application/Features/Testing/Client/Dtos/ClientCapabilities.cs create mode 100644 src/SharpIDE.Application/Features/Testing/Client/Dtos/ClientInfo.cs create mode 100644 src/SharpIDE.Application/Features/Testing/Client/Dtos/DiscoverRequest.cs create mode 100644 src/SharpIDE.Application/Features/Testing/Client/Dtos/InitializeRequest.cs create mode 100644 src/SharpIDE.Application/Features/Testing/Client/Dtos/InitializeResponse.cs create mode 100644 src/SharpIDE.Application/Features/Testing/Client/Dtos/LogLevel.cs create mode 100644 src/SharpIDE.Application/Features/Testing/Client/Dtos/RpcListener.cs create mode 100644 src/SharpIDE.Application/Features/Testing/Client/Dtos/RunRequest.cs create mode 100644 src/SharpIDE.Application/Features/Testing/Client/Dtos/ServerCapabilities.cs create mode 100644 src/SharpIDE.Application/Features/Testing/Client/Dtos/ServerInfo.cs create mode 100644 src/SharpIDE.Application/Features/Testing/Client/Dtos/TelemetryPayload.cs create mode 100644 src/SharpIDE.Application/Features/Testing/Client/Dtos/TestNode.cs create mode 100644 src/SharpIDE.Application/Features/Testing/Client/ExecutionStates.cs create mode 100644 src/SharpIDE.Application/Features/Testing/Client/LogsCollector.cs create mode 100644 src/SharpIDE.Application/Features/Testing/Client/RootFinder.cs create mode 100644 src/SharpIDE.Application/Features/Testing/Client/TelemetryCollector.cs create mode 100644 src/SharpIDE.Application/Features/Testing/Client/TestingPlatformClient.cs create mode 100644 src/SharpIDE.Application/Features/Testing/Client/TestingPlatformClientFactory.cs create mode 100644 src/SharpIDE.Application/Features/Testing/TestRunnerService.cs 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();