test runner v1

This commit is contained in:
Matt Parker
2025-11-04 00:47:23 +10:00
parent 066b10d7e9
commit b718b2c4e1
21 changed files with 954 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
using Newtonsoft.Json;
namespace SharpIDE.Application.Features.Testing.Client.Dtos;
public sealed record AttachDebuggerInfo(
[property:JsonProperty("processId")]
int ProcessId);

View File

@@ -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);

View File

@@ -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");

View File

@@ -0,0 +1,9 @@
using Newtonsoft.Json;
namespace SharpIDE.Application.Features.Testing.Client.Dtos;
public sealed record DiscoveryRequest(
[property:JsonProperty("runId")]
Guid RunId);

View File

@@ -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);

View File

@@ -0,0 +1,7 @@
namespace SharpIDE.Application.Features.Testing.Client.Dtos;
public sealed record InitializeResponse(
ServerInfo ServerInfo,
ServerCapabilities Capabilities);

View File

@@ -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,
}

View File

@@ -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);
}

View File

@@ -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);

View File

@@ -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);

View File

@@ -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");

View File

@@ -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);

View File

@@ -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);

View File

@@ -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";
}

View File

@@ -0,0 +1,5 @@
using System.Collections.Concurrent;
namespace SharpIDE.Application.Features.Testing.Client;
public class LogsCollector : ConcurrentBag<TestingPlatformClient.Log>;

View File

@@ -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.");
}
}

View File

@@ -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>;

View File

@@ -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);
}

View File

@@ -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"
];
}

View File

@@ -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();
}
}

View File

@@ -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>();