diff --git a/samples/Playground/Playground.csproj b/samples/Playground/Playground.csproj
index 57236d10af..dbf0ad2fc3 100644
--- a/samples/Playground/Playground.csproj
+++ b/samples/Playground/Playground.csproj
@@ -12,6 +12,16 @@
+
+
+
+
diff --git a/samples/Playground/Program.cs b/samples/Playground/Program.cs
index 1a5cf88f8a..6e138d7392 100644
--- a/samples/Playground/Program.cs
+++ b/samples/Playground/Program.cs
@@ -18,9 +18,37 @@ public static async Task Main(string[] args)
ITestApplicationBuilder testApplicationBuilder = await TestApplication.CreateBuilderAsync(args);
testApplicationBuilder.AddMSTest(() => [Assembly.GetEntryAssembly()!]);
- // Enable Trx
- // testApplicationBuilder.AddTrxReportProvider();
- using ITestApplication testApplication = await testApplicationBuilder.BuildAsync();
- return await testApplication.RunAsync();
+ // Enable Trx
+ // testApplicationBuilder.AddTrxReportProvider();
+
+ // Enable Telemetry
+ // testApplicationBuilder.AddAppInsightsTelemetryProvider();
+ using ITestApplication testApplication = await testApplicationBuilder.BuildAsync();
+ return await testApplication.RunAsync();
+ }
+ else
+ {
+ Environment.SetEnvironmentVariable("TESTSERVERMODE", "0");
+ using TestingPlatformClient client = await TestingPlatformClientFactory.StartAsServerAndConnectAsync(Environment.ProcessPath!, enableDiagnostic: true);
+
+ await client.InitializeAsync();
+ List testNodeUpdates = new();
+ ResponseListener discoveryResponse = await client.DiscoverTestsAsync(Guid.NewGuid(), node =>
+ {
+ testNodeUpdates.AddRange(node);
+ return Task.CompletedTask;
+ });
+ await discoveryResponse.WaitCompletionAsync();
+
+ ResponseListener runRequest = await client.RunTestsAsync(Guid.NewGuid(), testNodeUpdates.Select(x => x.Node).ToArray(), node =>
+ {
+ return Task.CompletedTask;
+ });
+ await runRequest.WaitCompletionAsync();
+
+ await client.ExitAsync();
+
+ return 0;
+ }
}
}
diff --git a/samples/Playground/ServerMode/TestingPlatformClientFactory.cs b/samples/Playground/ServerMode/TestingPlatformClientFactory.cs
new file mode 100644
index 0000000000..07333c78b5
--- /dev/null
+++ b/samples/Playground/ServerMode/TestingPlatformClientFactory.cs
@@ -0,0 +1,502 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using System.Collections;
+using System.Diagnostics;
+using System.Globalization;
+using System.Net;
+using System.Net.Sockets;
+using System.Text;
+using System.Text.RegularExpressions;
+
+using Microsoft.Testing.Platform.ServerMode.IntegrationTests.Messages.V100;
+
+namespace MSTest.Acceptance.IntegrationTests.Messages.V100;
+
+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 = (processHandle, 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 static async Task StartAsServerAndConnectAsync(string testApp, bool enableDiagnostic = false)
+ {
+ 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");
+ TaskCompletionSource portFound = new();
+ ProcessConfiguration processConfig = new(testApp)
+ {
+ OnStandardOutput =
+ (_, output) =>
+ {
+ Match m = ParsePort().Match(output);
+ if (m.Success && int.TryParse(m.Groups[1].Value, out int port))
+ {
+ portFound.SetResult(port);
+ }
+
+ // Do not remove pls
+ // NotepadWindow.WriteLine($"[OnStandardOutput] {output}");
+ },
+
+ // Do not remove pls
+ // OnErrorOutput = (_, output) => NotepadWindow.WriteLine($"[OnErrorOutput] {output}"),
+ OnErrorOutput = (_, output) =>
+ {
+ if (!portFound.Task.IsCompleted)
+ {
+ try
+ {
+ portFound.SetException(new InvalidOperationException(output));
+ }
+ catch (InvalidOperationException)
+ {
+ // possible race
+ }
+ }
+ },
+ OnExit = (processHandle, exitCode) =>
+ {
+ if (exitCode != 0)
+ {
+ if (portFound.Task.Exception is null && !portFound.Task.IsCompleted)
+ {
+ portFound.SetException(new InvalidOperationException($"Port not found during parsing and process exited with code '{exitCode}'"));
+ }
+ }
+ },
+
+ // OnExit = (_, exitCode) => NotepadWindow.WriteLine($"[OnExit] Process exit code '{exitCode}'"),
+ Arguments = "--server --diagnostic --diagnostic-verbosity trace",
+ // Arguments = "--server",
+ EnvironmentVariables = environmentVariables,
+ };
+
+ IProcessHandle processHandler = ProcessFactory.Start(processConfig, cleanDefaultEnvironmentVariableIfCustomAreProvided: false);
+ await portFound.Task;
+
+ var tcpClient = new TcpClient();
+ using CancellationTokenSource cancellationTokenSource = new(TimeSpan.FromSeconds(90));
+#pragma warning disable VSTHRD103 // Call async methods when in an async method
+ await tcpClient.ConnectAsync(new IPEndPoint(IPAddress.Loopback, portFound.Task.Result), cancellationTokenSource.Token);
+#pragma warning restore VSTHRD103 // Call async methods when in an async method
+ return new TestingPlatformClient(new(tcpClient.GetStream()), tcpClient, processHandler, enableDiagnostic);
+ }
+
+ [GeneratedRegex(@"Starting server. Listening on port '(\d+)'")]
+ private static partial Regex ParsePort();
+}
+
+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);
+
+ process.Exited += (s, e) => 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)
+ {
+ return _exitCode;
+ }
+#if NETCOREAPP
+ await _process.WaitForExitAsync();
+#else
+ _process.WaitForExit();
+#endif
+ return await Task.FromResult(_process.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
+ {
+#if NETCOREAPP
+ process.Kill(true);
+#else
+ process.Kill();
+#endif
+ }
+ 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"
+ ];
+}
+
+public static class RootFinder
+{
+ public static string Find()
+ {
+ string path = AppContext.BaseDirectory;
+ string dir = path;
+ while (Directory.GetDirectoryRoot(dir) != dir)
+ {
+ if (Directory.Exists(Path.Combine(dir, ".git")))
+ {
+ return dir;
+ }
+ else
+ {
+ dir = Directory.GetParent(dir)!.ToString();
+ }
+ }
+
+ throw new InvalidOperationException($"Could not find solution root, .git not found in {path} or any parent directory.");
+ }
+}
diff --git a/samples/Playground/ServerMode/v1.0.0/TestingPlatformClient.cs b/samples/Playground/ServerMode/v1.0.0/TestingPlatformClient.cs
new file mode 100644
index 0000000000..5cab289e33
--- /dev/null
+++ b/samples/Playground/ServerMode/v1.0.0/TestingPlatformClient.cs
@@ -0,0 +1,264 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using System.Collections.Concurrent;
+using System.Diagnostics;
+using System.Net.Sockets;
+using System.Text;
+
+using MSTest.Acceptance.IntegrationTests.Messages.V100;
+
+using StreamJsonRpc;
+
+using AttachDebuggerInfo = MSTest.Acceptance.IntegrationTests.Messages.V100.AttachDebuggerInfo;
+
+namespace Microsoft.Testing.Platform.ServerMode.IntegrationTests.Messages.V100;
+
+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)
+ {
+ if (_disconnectionReason.Length > 0)
+ {
+ throw new InvalidOperationException($"{ex.Message}\n{_disconnectionReason}", ex);
+ }
+
+ throw;
+ }
+ }
+
+ 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 V100.ClientInfo("test-client"),
+ new MSTest.Acceptance.IntegrationTests.Messages.V100.ClientCapabilities(new MSTest.Acceptance.IntegrationTests.Messages.V100.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
+ = new();
+
+ private readonly ConcurrentBag _telemetryPayloads
+ = new();
+
+ 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 _);
+ return;
+ }
+ else
+ {
+ await responseListener.OnMessageReceiveAsync(changes);
+ }
+ }
+ }
+
+ [JsonRpcMethod("telemetry/update", UseSingleObjectParameterDeserialization = true)]
+ public Task TelemetryAsync(Microsoft.Testing.Platform.ServerMode.IntegrationTests.Messages.V100.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/Platform/Microsoft.Testing.Platform/Hosts/CommonTestHost.cs b/src/Platform/Microsoft.Testing.Platform/Hosts/CommonTestHost.cs
index 94d17f2d49..0816a71e87 100644
--- a/src/Platform/Microsoft.Testing.Platform/Hosts/CommonTestHost.cs
+++ b/src/Platform/Microsoft.Testing.Platform/Hosts/CommonTestHost.cs
@@ -55,6 +55,7 @@ public async Task RunAsync()
{
await DisposeServiceProviderAsync(ServiceProvider, isProcessShutdown: true);
await DisposeHelper.DisposeAsync(ServiceProvider.GetService());
+ await DisposeHelper.DisposeAsync(ServiceProvider.GetTestApplicationCancellationTokenSource());
}
return exitCode;
@@ -165,6 +166,13 @@ protected static async Task DisposeServiceProviderAsync(ServiceProvider serviceP
continue;
}
+ // The ITestApplicationCancellationTokenSource contains the cancellation token and can be used by other services during the shutdown
+ // we will collect manually in the correct moment.
+ if (service is ITestApplicationCancellationTokenSource)
+ {
+ continue;
+ }
+
if (filter is not null && !filter(service))
{
continue;
@@ -173,7 +181,6 @@ protected static async Task DisposeServiceProviderAsync(ServiceProvider serviceP
// We need to ensure that we won't dispose special services till the shutdown
if (!isProcessShutdown &&
service is ITelemetryCollector or
- ITestApplicationCancellationTokenSource or
ITestApplicationLifecycleCallbacks)
{
continue;
diff --git a/src/Platform/Microsoft.Testing.Platform/Hosts/ServerTestHost.cs b/src/Platform/Microsoft.Testing.Platform/Hosts/ServerTestHost.cs
index 481d371c3d..98e6b9b52a 100644
--- a/src/Platform/Microsoft.Testing.Platform/Hosts/ServerTestHost.cs
+++ b/src/Platform/Microsoft.Testing.Platform/Hosts/ServerTestHost.cs
@@ -682,6 +682,9 @@ await SendMessageAsync(
method: JsonRpcMethods.ClientLog,
@params: new LogEventArgs(logMessage),
_testApplicationCancellationTokenSource.CancellationToken,
+
+ // We could receive some log messages after the exit, a real sample is if telemetry provider is too slow and we log a warning.
+ checkServerExit: true,
rethrowException: false);
break;
}