test runner v1
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace SharpIDE.Application.Features.Testing.Client.Dtos;
|
||||
|
||||
public sealed record AttachDebuggerInfo(
|
||||
[property:JsonProperty("processId")]
|
||||
int ProcessId);
|
||||
@@ -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);
|
||||
@@ -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");
|
||||
@@ -0,0 +1,9 @@
|
||||
|
||||
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace SharpIDE.Application.Features.Testing.Client.Dtos;
|
||||
|
||||
public sealed record DiscoveryRequest(
|
||||
[property:JsonProperty("runId")]
|
||||
Guid RunId);
|
||||
@@ -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);
|
||||
@@ -0,0 +1,7 @@
|
||||
|
||||
|
||||
namespace SharpIDE.Application.Features.Testing.Client.Dtos;
|
||||
|
||||
public sealed record InitializeResponse(
|
||||
ServerInfo ServerInfo,
|
||||
ServerCapabilities Capabilities);
|
||||
@@ -0,0 +1,44 @@
|
||||
|
||||
|
||||
namespace SharpIDE.Application.Features.Testing.Client.Dtos;
|
||||
|
||||
/// <summary>
|
||||
/// Log level.
|
||||
/// </summary>
|
||||
public enum LogLevel
|
||||
{
|
||||
/// <summary>
|
||||
/// Trace.
|
||||
/// </summary>
|
||||
Trace = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Debug.
|
||||
/// </summary>
|
||||
Debug = 1,
|
||||
|
||||
/// <summary>
|
||||
/// Information.
|
||||
/// </summary>
|
||||
Information = 2,
|
||||
|
||||
/// <summary>
|
||||
/// Warning.
|
||||
/// </summary>
|
||||
Warning = 3,
|
||||
|
||||
/// <summary>
|
||||
/// Error.
|
||||
/// </summary>
|
||||
Error = 4,
|
||||
|
||||
/// <summary>
|
||||
/// Critical.
|
||||
/// </summary>
|
||||
Critical = 5,
|
||||
|
||||
/// <summary>
|
||||
/// None.
|
||||
/// </summary>
|
||||
None = 6,
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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");
|
||||
@@ -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<string, string> Metrics);
|
||||
@@ -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);
|
||||
@@ -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";
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace SharpIDE.Application.Features.Testing.Client;
|
||||
|
||||
public class LogsCollector : ConcurrentBag<TestingPlatformClient.Log>;
|
||||
@@ -0,0 +1,54 @@
|
||||
|
||||
|
||||
namespace SharpIDE.Application.Features.Testing.Client;
|
||||
|
||||
/// <summary>
|
||||
/// Provides functionality to locate the root directory of a Git repository.
|
||||
/// </summary>
|
||||
/// <remarks>The <see cref="RootFinder"/> 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.</remarks>
|
||||
#if ROOT_FINDER_PUBLIC
|
||||
public
|
||||
#else
|
||||
internal
|
||||
#endif
|
||||
static class RootFinder
|
||||
{
|
||||
private static string? s_root;
|
||||
|
||||
/// <summary>
|
||||
/// Finds the root directory of a Git repository starting from the application's base directory.
|
||||
/// </summary>
|
||||
/// <remarks>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 <see cref="InvalidOperationException"/> is thrown.</remarks>
|
||||
/// <returns>The path to the root directory of the Git repository, ending with a directory separator character.</returns>
|
||||
/// <exception cref="InvalidOperationException">Thrown if a Git repository is not found in the application's base directory or any of its parent directories.</exception>
|
||||
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.");
|
||||
}
|
||||
}
|
||||
@@ -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<TelemetryPayload>;
|
||||
@@ -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<int> WaitServerProcessExitAsync()
|
||||
{
|
||||
await _processHandler.WaitForExitAsync();
|
||||
return _processHandler.ExitCode;
|
||||
}
|
||||
|
||||
public JsonRpc JsonRpcClient { get; }
|
||||
|
||||
private async Task CheckedInvokeAsync(Func<Task> func)
|
||||
{
|
||||
try
|
||||
{
|
||||
await func();
|
||||
}
|
||||
catch (Exception ex) when (_disconnectionReason.Length > 0)
|
||||
{
|
||||
throw new InvalidOperationException($"{ex.Message}\n{_disconnectionReason}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<T> CheckedInvokeAsync<T>(Func<Task<T>> 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<InitializeResponse> InitializeAsync()
|
||||
{
|
||||
using CancellationTokenSource cancellationTokenSource = new(TimeSpan.FromMinutes(3));
|
||||
return await CheckedInvokeAsync(async () => await JsonRpcClient.InvokeWithParameterObjectAsync<InitializeResponse>(
|
||||
"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<ResponseListener> DiscoverTestsAsync(Guid requestId, Func<TestNodeUpdate[], Task> 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<ResponseListener> RunTestsAsync(Guid requestId, Func<TestNodeUpdate[], Task> 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<ResponseListener> RunTestsAsync(Guid requestId, TestNode[] filter, Func<TestNodeUpdate[], Task> 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<Guid, ResponseListener> _listeners
|
||||
= new();
|
||||
|
||||
private readonly ConcurrentBag<LogsCollector> _logListeners = [];
|
||||
|
||||
private readonly ConcurrentBag<TelemetryCollector> _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<TestNodeUpdate[], Task> _action;
|
||||
|
||||
public TestNodeUpdatesResponseListener(Guid requestId, Func<TestNodeUpdate[], Task> action)
|
||||
: base(requestId) => _action = action;
|
||||
|
||||
public override async Task OnMessageReceiveAsync(object message)
|
||||
=> await _action((TestNodeUpdate[])message);
|
||||
}
|
||||
@@ -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<string, string> 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<TestingPlatformClient> StartAsServerAndConnectToTheClientAsync(string testApp)
|
||||
{
|
||||
var environmentVariables = new Dictionary<string, string>(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<string, string>? EnvironmentVariables { get; init; }
|
||||
|
||||
public Action<IProcessHandle, string>? OnErrorOutput { get; init; }
|
||||
|
||||
public Action<IProcessHandle, string>? OnStandardOutput { get; init; }
|
||||
|
||||
public Action<IProcessHandle, int>? 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<int> StopAsync();
|
||||
Task<int> 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<string, string> 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 ?? "<unknown>";
|
||||
|
||||
public int Id => _processHandleInfo.Id;
|
||||
|
||||
public TextWriter StandardInput => _process.StandardInput;
|
||||
|
||||
public TextReader StandardOutput => _process.StandardOutput;
|
||||
|
||||
public int ExitCode => _process.ExitCode;
|
||||
|
||||
public async Task<int> WaitForExitAsync()
|
||||
{
|
||||
if (!_disposed)
|
||||
{
|
||||
await _process.WaitForExitAsync();
|
||||
}
|
||||
|
||||
return _exitCode;
|
||||
}
|
||||
|
||||
public void WaitForExit() => _process.WaitForExit();
|
||||
|
||||
public async Task<int> 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"
|
||||
];
|
||||
}
|
||||
@@ -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<TestNodeUpdate> 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 <TestNodeUpdate> 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();
|
||||
}
|
||||
}
|
||||
@@ -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<FileChangedService>();
|
||||
services.AddScoped<DotnetUserSecretsService>();
|
||||
services.AddScoped<NugetClientService>();
|
||||
services.AddScoped<TestRunnerService>();
|
||||
services.AddScoped<NugetPackageIconCacheService>();
|
||||
services.AddScoped<IdeFileWatcher>();
|
||||
services.AddScoped<IdeNavigationHistoryService>();
|
||||
|
||||
Reference in New Issue
Block a user