diff --git a/TUnit.Pipeline/Modules/RunRpcTestsModule.cs b/TUnit.Pipeline/Modules/RunRpcTestsModule.cs index fd029bc7ee..b422fff5b4 100644 --- a/TUnit.Pipeline/Modules/RunRpcTestsModule.cs +++ b/TUnit.Pipeline/Modules/RunRpcTestsModule.cs @@ -1,5 +1,4 @@ -using System.Runtime.InteropServices; -using ModularPipelines.Attributes; +using ModularPipelines.Attributes; using ModularPipelines.Context; using ModularPipelines.DotNet.Options; using ModularPipelines.Extensions; @@ -12,8 +11,10 @@ namespace TUnit.Pipeline.Modules; [NotInParallel("NetworkTests")] public class RunRpcTestsModule : TestBaseModule { - // Skipped globally — see https://github.com/thomhurst/TUnit/issues/5540 - protected override IEnumerable TestableFrameworks => []; + protected override IEnumerable TestableFrameworks => + [ + "net10.0" + ]; protected override Task<(DotNetRunOptions Options, CommandExecutionOptions? ExecutionOptions)> GetTestOptions(IModuleContext context, string framework, CancellationToken cancellationToken) { diff --git a/TUnit.RpcTests/Clients/IProcessHandle.cs b/TUnit.RpcTests/Clients/IProcessHandle.cs deleted file mode 100644 index bcc36d6e54..0000000000 --- a/TUnit.RpcTests/Clients/IProcessHandle.cs +++ /dev/null @@ -1,24 +0,0 @@ -namespace TUnit.RpcTests.Clients; - -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(); - - Task WriteInputAsync(string input); -} diff --git a/TUnit.RpcTests/Clients/ProcessHandle.cs b/TUnit.RpcTests/Clients/ProcessHandle.cs deleted file mode 100644 index 0edb825808..0000000000 --- a/TUnit.RpcTests/Clients/ProcessHandle.cs +++ /dev/null @@ -1,37 +0,0 @@ -using CliWrap; - -namespace TUnit.RpcTests.Clients; - -public class ProcessHandle(CommandTask commandTask, Stream output) : IProcessHandle -{ - public int Id { get; } = commandTask.ProcessId; - public string ProcessName { get; } = "dotnet"; - public int ExitCode { get; private set; } - public TextWriter StandardInput => new StringWriter(); - public TextReader StandardOutput => new StreamReader(output); - public void Dispose() - { - commandTask.Dispose(); - } - - public void Kill() - { - Dispose(); - } - - public Task StopAsync() - { - throw new NotImplementedException(); - } - - public async Task WaitForExitAsync() - { - var commandResult = await commandTask; - return ExitCode = commandResult.ExitCode; - } - - public Task WriteInputAsync(string input) - { - return Task.CompletedTask; - } -} diff --git a/TUnit.RpcTests/Clients/TestingPlatformClient.cs b/TUnit.RpcTests/Clients/TestingPlatformClient.cs index ae0e106ee4..561b817799 100644 --- a/TUnit.RpcTests/Clients/TestingPlatformClient.cs +++ b/TUnit.RpcTests/Clients/TestingPlatformClient.cs @@ -1,25 +1,22 @@ -using System.Collections.Concurrent; -using System.Diagnostics; +using System.Collections.Concurrent; using System.Net.Sockets; using System.Text; +using Microsoft.Extensions.Logging; using StreamJsonRpc; using TUnit.RpcTests.Models; -// ReSharper disable All namespace TUnit.RpcTests.Clients; public sealed class TestingPlatformClient : IDisposable { - private readonly TcpClient _tcpClient = new(); - private readonly IProcessHandle _processHandler; + private readonly TcpClient _tcpClient; private readonly TargetHandler _targetHandler = new(); private readonly StringBuilder _disconnectionReason = new(); - public TestingPlatformClient(JsonRpc jsonRpc, TcpClient tcpClient, IProcessHandle processHandler, bool enableDiagnostic = false) + public TestingPlatformClient(JsonRpc jsonRpc, TcpClient tcpClient) { JsonRpcClient = jsonRpc; _tcpClient = tcpClient; - _processHandler = processHandler; JsonRpcClient.AddLocalRpcTarget( _targetHandler, new JsonRpcTargetOptions @@ -27,12 +24,6 @@ public TestingPlatformClient(JsonRpc jsonRpc, TcpClient tcpClient, IProcessHandl MethodNameTransform = CommonMethodNameTransforms.CamelCase, }); - if (enableDiagnostic) - { - JsonRpcClient.TraceSource.Switch.Level = SourceLevels.All; - JsonRpcClient.TraceSource.Listeners.Add(new ConsoleRpcListener()); - } - JsonRpcClient.Disconnected += JsonRpcClient_Disconnected; JsonRpcClient.StartListening(); } @@ -45,53 +36,18 @@ private void JsonRpcClient_Disconnected(object? sender, JsonRpcDisconnectedEvent _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) + private async Task CheckedInvokeAsync(Func> func) { try { return await func(); } - catch (Exception ex) + catch (Exception ex) when (_disconnectionReason.Length > 0) { - if (@checked) - { - if (_disconnectionReason.Length > 0) - { - throw new InvalidOperationException($"{ex.Message}\n{_disconnectionReason}", ex); - } - - throw; - } + throw new InvalidOperationException($"{ex.Message}\n{_disconnectionReason}", ex); } - - return default!; } public void RegisterLogListener(LogsCollector listener) @@ -100,47 +56,41 @@ public void RegisterLogListener(LogsCollector 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( + public async Task InitializeAsync(CancellationToken cancellationToken = default) + => await CheckedInvokeAsync(async () => await JsonRpcClient.InvokeWithParameterObjectAsync( "initialize", new InitializeRequest(Environment.ProcessId, new ClientInfo("test-client"), - new ClientCapabilities(new ClientTestingCapabilities(DebuggerProvider: false))), cancellationToken: cancellationTokenSource.Token)); - } + new ClientCapabilities(new ClientTestingCapabilities(DebuggerProvider: false))), + cancellationToken: cancellationToken)); - public async Task ExitAsync(bool gracefully = true) + public async Task ExitAsync() { - if (gracefully) + try { - using CancellationTokenSource cancellationTokenSource = new(TimeSpan.FromMinutes(3)); - await CheckedInvokeAsync(async () => await JsonRpcClient.NotifyWithParameterObjectAsync("exit", new object())); + await JsonRpcClient.NotifyWithParameterObjectAsync("exit", new object()); } - else + catch { - _tcpClient.Dispose(); + // Best effort — connection may already be gone } } - 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, TestNode[]? testNodes = null) + public async Task DiscoverTestsAsync(Guid requestId, Func action, CancellationToken cancellationToken = default) + => await CheckedInvokeAsync(async () => + { + var discoveryListener = new TestNodeUpdatesResponseListener(requestId, action); + _targetHandler.RegisterResponseListener(discoveryListener); + await JsonRpcClient.InvokeWithParameterObjectAsync("testing/discoverTests", new DiscoveryRequest(RunId: requestId), cancellationToken: cancellationToken); + return (ResponseListener)discoveryListener; + }); + + public async Task RunTestsAsync(Guid requestId, Func action, TestNode[]? testNodes = null, CancellationToken cancellationToken = default) => 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 RunTestsRequest(RunId: requestId, TestCases: testNodes), cancellationToken: cancellationTokenSource.Token); - return runListener; + await JsonRpcClient.InvokeWithParameterObjectAsync("testing/runTests", new RunTestsRequest(RunId: requestId, Tests: testNodes), cancellationToken: cancellationToken); + return (ResponseListener)runListener; }); public void Dispose() @@ -153,14 +103,9 @@ 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(); + private readonly ConcurrentDictionary _listeners = new(); + private readonly ConcurrentBag _logListeners = []; + private readonly ConcurrentBag _telemetryPayloads = []; public void RegisterTelemetryListener(TelemetryCollector listener) => _telemetryPayloads.Add(listener); @@ -233,5 +178,5 @@ public sealed class TestNodeUpdatesResponseListener(Guid requestId, Func await action((TestNodeUpdate[]) message); + => await action((TestNodeUpdate[])message); } diff --git a/TUnit.RpcTests/Models/ConsoleRpcListener.cs b/TUnit.RpcTests/Models/ConsoleRpcListener.cs deleted file mode 100644 index c18eac1c4e..0000000000 --- a/TUnit.RpcTests/Models/ConsoleRpcListener.cs +++ /dev/null @@ -1,10 +0,0 @@ -using System.Diagnostics; - -namespace TUnit.RpcTests.Models; - -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/TUnit.RpcTests/Models/LogLevel.cs b/TUnit.RpcTests/Models/LogLevel.cs deleted file mode 100644 index fb5e386657..0000000000 --- a/TUnit.RpcTests/Models/LogLevel.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace TUnit.RpcTests.Models; - -public enum LogLevel -{ - Trace = 0, - Debug = 1, - Information = 2, - Warning = 3, - Error = 4, - Critical = 5, - None = 6, -} diff --git a/TUnit.RpcTests/Models/RunRequest.cs b/TUnit.RpcTests/Models/RunRequest.cs deleted file mode 100644 index 7066048f89..0000000000 --- a/TUnit.RpcTests/Models/RunRequest.cs +++ /dev/null @@ -1,7 +0,0 @@ -using System.Text.Json.Serialization; - -namespace TUnit.RpcTests.Models; - -public sealed record RunRequest( - [property:JsonPropertyName("runId")] - Guid RunId); diff --git a/TUnit.RpcTests/Models/RunTestsRequest.cs b/TUnit.RpcTests/Models/RunTestsRequest.cs index f4ad553297..66ba8edf7d 100644 --- a/TUnit.RpcTests/Models/RunTestsRequest.cs +++ b/TUnit.RpcTests/Models/RunTestsRequest.cs @@ -5,5 +5,5 @@ namespace TUnit.RpcTests.Models; public sealed record RunTestsRequest( [property:JsonPropertyName("runId")] Guid RunId, - [property:JsonPropertyName("testCases")] - TestNode[]? TestCases = null); + [property:JsonPropertyName("tests")] + TestNode[]? Tests = null); diff --git a/TUnit.RpcTests/Models/TestNode.cs b/TUnit.RpcTests/Models/TestNode.cs index 88f79b6a62..b980a3ae9a 100644 --- a/TUnit.RpcTests/Models/TestNode.cs +++ b/TUnit.RpcTests/Models/TestNode.cs @@ -1,17 +1,25 @@ -using System.Text.Json.Serialization; +using System.Text.Json; +using System.Text.Json.Serialization; namespace TUnit.RpcTests.Models; public sealed record TestNode -( - [property: JsonPropertyName("uid")] - string Uid, +{ + [JsonPropertyName("uid")] + public required string Uid { get; init; } - [property: JsonPropertyName("display-name")] - string DisplayName, + [JsonPropertyName("display-name")] + public required string DisplayName { get; init; } - [property: JsonPropertyName("node-type")] - string NodeType, + [JsonPropertyName("node-type")] + public string? NodeType { get; init; } - [property: JsonPropertyName("execution-state")] - string ExecutionState); + [JsonPropertyName("execution-state")] + public string? ExecutionState { get; init; } + + // Captures every other server-sent field (traits, location.*, error.*, etc.) + // so that when we round-trip the node back in testCases, the server sees + // the full payload it originally produced. + [JsonExtensionData] + public Dictionary? ExtensionData { get; init; } +} diff --git a/TUnit.RpcTests/Models/TestNodeUpdate.cs b/TUnit.RpcTests/Models/TestNodeUpdate.cs index def64f4ca8..c92e611fa1 100644 --- a/TUnit.RpcTests/Models/TestNodeUpdate.cs +++ b/TUnit.RpcTests/Models/TestNodeUpdate.cs @@ -1,4 +1,4 @@ -using System.Text.Json.Serialization; +using System.Text.Json.Serialization; namespace TUnit.RpcTests.Models; @@ -8,4 +8,4 @@ public sealed record TestNodeUpdate TestNode Node, [property: JsonPropertyName("parent")] - string ParentUid); + string? ParentUid); diff --git a/TUnit.RpcTests/RpcJsonSerializerOptions.cs b/TUnit.RpcTests/RpcJsonSerializerOptions.cs index 3989654084..d37ff9617e 100644 --- a/TUnit.RpcTests/RpcJsonSerializerOptions.cs +++ b/TUnit.RpcTests/RpcJsonSerializerOptions.cs @@ -1,4 +1,5 @@ -using System.Text.Json; +using System.Text.Json; +using System.Text.Json.Serialization; namespace TUnit.RpcTests; @@ -12,6 +13,10 @@ static RpcJsonSerializerOptions() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, PropertyNameCaseInsensitive = true, + Converters = + { + new JsonStringEnumConverter(JsonNamingPolicy.CamelCase, allowIntegerValues: true), + }, }; } } diff --git a/TUnit.RpcTests/TestHostSession.cs b/TUnit.RpcTests/TestHostSession.cs new file mode 100644 index 0000000000..9ed8b51f6c --- /dev/null +++ b/TUnit.RpcTests/TestHostSession.cs @@ -0,0 +1,159 @@ +using System.Net; +using System.Net.Sockets; +using CliWrap; +using StreamJsonRpc; +using TUnit.RpcTests.Clients; +using TUnit.RpcTests.Models; + +namespace TUnit.RpcTests; + +/// +/// Owns a running TUnit.TestProject subprocess in --server mode plus the +/// JSON-RPC connection to it. Use to spin one up and +/// dispose it to tear everything down. +/// +internal sealed class TestHostSession : IAsyncDisposable +{ + private readonly TcpListener _listener; + private readonly CommandTask _cliProcess; + private readonly TcpClient _tcpClient; + private readonly NetworkStream _stream; + private readonly JsonRpc _rpc; + + public TestingPlatformClient Client { get; } + + private TestHostSession( + TcpListener listener, + CommandTask cliProcess, + TcpClient tcpClient, + NetworkStream stream, + JsonRpc rpc, + TestingPlatformClient client) + { + _listener = listener; + _cliProcess = cliProcess; + _tcpClient = tcpClient; + _stream = stream; + _rpc = rpc; + Client = client; + } + + public static async Task StartAsync(string framework, CancellationToken cancellationToken) + { + await TestProjectBuilds.EnsureBuiltAsync(framework, cancellationToken); + + var listener = new TcpListener(new IPEndPoint(IPAddress.Loopback, 0)); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + + var cliProcess = Cli.Wrap("dotnet") + .WithWorkingDirectory(TestProjectBuilds.WorkingDirectory) + .WithArguments([ + "run", + "--no-build", + "-c", "Debug", + "-f", framework, + "--server", + "--client-port", port.ToString() + ]) + .WithStandardOutputPipe(PipeTarget.Null) + .WithStandardErrorPipe(PipeTarget.Null) + .WithValidation(CommandResultValidation.None) + .ExecuteAsync(cancellationToken); + + var tcpClient = await AcceptWithTimeoutAsync(listener, cliProcess.Task, TimeSpan.FromSeconds(60), port, cancellationToken); + + var stream = tcpClient.GetStream(); + var rpc = new JsonRpc(new HeaderDelimitedMessageHandler(stream, stream, new SystemTextJsonFormatter + { + JsonSerializerOptions = RpcJsonSerializerOptions.Default + })); + + var client = new TestingPlatformClient(rpc, tcpClient); + + using (var initCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken)) + { + initCts.CancelAfter(TimeSpan.FromSeconds(30)); + await client.InitializeAsync(initCts.Token); + } + + return new TestHostSession(listener, cliProcess, tcpClient, stream, rpc, client); + } + + private static async Task AcceptWithTimeoutAsync( + TcpListener listener, + Task cliProcessTask, + TimeSpan timeout, + int port, + CancellationToken cancellationToken) + { + var acceptTask = listener.AcceptTcpClientAsync(cancellationToken).AsTask(); + var timeoutTask = Task.Delay(timeout, cancellationToken); + var completed = await Task.WhenAny(cliProcessTask, acceptTask, timeoutTask); + + if (completed == timeoutTask) + { + throw new TimeoutException($"Timeout waiting for TCP connection after {timeout.TotalSeconds}s on port {port}"); + } + + if (completed == cliProcessTask) + { + var result = await cliProcessTask; + throw new InvalidOperationException($"Test host exited unexpectedly before connecting (exit code {result.ExitCode})"); + } + + return await acceptTask; + } + + public async Task> DiscoverAsync(CancellationToken cancellationToken) + { + var updates = new List(); + var response = await Client.DiscoverTestsAsync(Guid.NewGuid(), batch => + { + lock (updates) updates.AddRange(batch); + return Task.CompletedTask; + }, cancellationToken); + + await response.WaitCompletionAsync(); + return updates; + } + + public async Task> RunAsync(TestNode[]? tests, CancellationToken cancellationToken) + { + var updates = new List(); + var response = await Client.RunTestsAsync(Guid.NewGuid(), batch => + { + lock (updates) updates.AddRange(batch); + return Task.CompletedTask; + }, tests, cancellationToken); + + await response.WaitCompletionAsync(); + return updates; + } + + public async ValueTask DisposeAsync() + { + try + { + await Client.ExitAsync(); + } + finally + { + Client.Dispose(); + await _stream.DisposeAsync(); + _tcpClient.Dispose(); + _rpc.Dispose(); + _listener.Stop(); + + try + { + using var killCts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await _cliProcess.Task.WaitAsync(killCts.Token); + } + catch + { + // Best effort — subprocess may already be gone or stuck + } + } + } +} diff --git a/TUnit.RpcTests/TestProjectBuilds.cs b/TUnit.RpcTests/TestProjectBuilds.cs new file mode 100644 index 0000000000..94cff67706 --- /dev/null +++ b/TUnit.RpcTests/TestProjectBuilds.cs @@ -0,0 +1,32 @@ +using System.Collections.Concurrent; +using CliWrap; + +namespace TUnit.RpcTests; + +/// +/// Memoizes pre-builds of TUnit.TestProject keyed by TFM so parallel / repeat +/// test runs for the same framework share a single dotnet build. +/// +internal static class TestProjectBuilds +{ + private static readonly ConcurrentDictionary _builds = new(); + + public static string WorkingDirectory { get; } = Sourcy.DotNet.Projects.TUnit_TestProject.DirectoryName!; + + public static Task EnsureBuiltAsync(string framework, CancellationToken cancellationToken) + => _builds.GetOrAdd(framework, tfm => BuildAsync(tfm, cancellationToken)); + + private static async Task BuildAsync(string framework, CancellationToken cancellationToken) + { + var result = await Cli.Wrap("dotnet") + .WithWorkingDirectory(WorkingDirectory) + .WithArguments(["build", "-c", "Debug", "-f", framework, "--nologo", "-v", "quiet"]) + .WithValidation(CommandResultValidation.None) + .ExecuteAsync(cancellationToken); + + if (result.ExitCode != 0) + { + throw new InvalidOperationException($"Test host pre-build ({framework}) failed with exit code {result.ExitCode}"); + } + } +} diff --git a/TUnit.RpcTests/Tests.cs b/TUnit.RpcTests/Tests.cs index de0411155a..e5d41b7208 100644 --- a/TUnit.RpcTests/Tests.cs +++ b/TUnit.RpcTests/Tests.cs @@ -1,245 +1,82 @@ -using System.Net; -using System.Net.Sockets; -using CliWrap; -using StreamJsonRpc; -using TUnit.RpcTests.Clients; using TUnit.RpcTests.Models; namespace TUnit.RpcTests; +[NotInParallel(nameof(Tests))] public class Tests { - [Timeout(300_000)] - [Retry(3)] - [Test, Skip("TODO: Fix RPC tests")] - public async Task TestAsync(CancellationToken cancellationToken) + public static IEnumerable> Frameworks() { - try - { - // Check if we can bind to localhost - await CheckNetworkConnectivity(); - - // Run all tests (no filter) - await RunTestsAsync(cancellationToken, testUidFilter: node => node.Uid.StartsWith("TUnit.TestProject.BasicTests.1.1")); - - // Or run specific tests by filtering on discovered test UIDs - // Example: await RunTestsAsync(cancellationToken, testUidFilter: uid => uid.Contains("BasicTests")); - } - catch (Exception ex) - { - Console.WriteLine($"[RPC Test] Test failed with exception: {ex}"); - throw; - } + yield return () => "net8.0"; + yield return () => "net10.0"; } - private async Task CheckNetworkConnectivity() + [Test] + [Timeout(300_000)] + [Retry(3)] + [MethodDataSource(nameof(Frameworks))] + public async Task Discovery_ReturnsFullTestCatalogue(string framework, CancellationToken cancellationToken) { - Console.WriteLine("[RPC Test] Checking network connectivity..."); + await using var session = await TestHostSession.StartAsync(framework, cancellationToken); - try - { - // Test if we can create a TCP listener - using var testListener = new TcpListener(IPAddress.Loopback, 0); - testListener.Start(); - var testPort = ((IPEndPoint)testListener.LocalEndpoint).Port; - Console.WriteLine($"[RPC Test] Successfully created test listener on port {testPort}"); - - // Test if we can connect to ourselves - using var testClient = new TcpClient(); - await testClient.ConnectAsync(IPAddress.Loopback, testPort); - Console.WriteLine("[RPC Test] Successfully connected to test listener"); - - testListener.Stop(); - } - catch (Exception ex) - { - Console.WriteLine($"[RPC Test] Network connectivity check failed: {ex.Message}"); - throw new InvalidOperationException("Network connectivity check failed. This might be due to firewall restrictions.", ex); - } + var updates = await session.DiscoverAsync(cancellationToken); + + var discovered = updates.Where(x => x.Node.ExecutionState is "discovered").ToArray(); + await Assert.That(discovered).Count().IsGreaterThanOrEqualTo(4000); } - private async Task RunTestsAsync(CancellationToken cancellationToken, Func? testUidFilter = null) + [Test] + [Timeout(300_000)] + [Retry(3)] + [MethodDataSource(nameof(Frameworks))] + public async Task RunTests_WithUidFilter_ExecutesOnlySelectedTests(string framework, CancellationToken cancellationToken) { - Console.WriteLine($"[RPC Test] Starting RPC test at {DateTime.Now:HH:mm:ss.fff}"); - - // Open a port that the test could listen on - var listener = new TcpListener(new IPEndPoint(IPAddress.Any, 0)); - listener.Start(); - - var port = ((IPEndPoint)listener.LocalEndpoint).Port; - Console.WriteLine($"[RPC Test] TCP listener started on port {port}"); - - await using var _ = cancellationToken.Register(() => - { - Console.WriteLine("[RPC Test] Cancellation requested, stopping listener"); - listener.Stop(); - }); - - await using var output = new MemoryStream(); - - var outputPipe = PipeTarget.ToStream(output); - - // Start the test host and accept the connection from the test host - var workingDir = Sourcy.DotNet.Projects.TUnit_TestProject.DirectoryName!; - Console.WriteLine($"[RPC Test] Starting test host process in: {workingDir}"); - - // Verify working directory exists - if (!Directory.Exists(workingDir)) - { - throw new DirectoryNotFoundException($"Test project directory not found: {workingDir}"); - } - - // Check if project file exists - var projectFile = Path.Combine(workingDir, "TUnit.TestProject.csproj"); - if (!File.Exists(projectFile)) - { - throw new FileNotFoundException($"Project file not found: {projectFile}"); - } - - Console.WriteLine($"[RPC Test] Command: dotnet run -f net8.0 --server --client-port {port}"); - - var cliProcess = Cli.Wrap("dotnet") - .WithWorkingDirectory(workingDir) - .WithArguments([ - "run", - "-f", "net8.0", - "--server", - "--client-port", - port.ToString() - ]) - .WithStandardOutputPipe(PipeTarget.Merge( - outputPipe, - PipeTarget.ToDelegate(line => Console.WriteLine($"[TestHost] {line}")) - )) - .WithStandardErrorPipe(PipeTarget.Merge( - outputPipe, - PipeTarget.ToDelegate(line => Console.WriteLine($"[TestHost ERR] {line}")) - )) - .ExecuteAsync(cancellationToken: cancellationToken); - - Console.WriteLine("[RPC Test] Waiting for TCP connection from test host..."); - var tcpClientTask = listener.AcceptTcpClientAsync(cancellationToken).AsTask(); - - // Add timeout for connection - var connectionTimeout = Task.Delay(TimeSpan.FromSeconds(30), cancellationToken); - var cliProcessTask = cliProcess.Task; - var completedTask = await Task.WhenAny(cliProcessTask, tcpClientTask, connectionTimeout); - - if (completedTask == connectionTimeout) - { - throw new TimeoutException($"[RPC Test] Timeout waiting for TCP connection after 30 seconds. Port: {port}"); - } - - if (completedTask == cliProcessTask) - { - var result = await cliProcessTask; - throw new InvalidOperationException($"[RPC Test] Test host process exited unexpectedly with code {result.ExitCode}"); - } + await using var session = await TestHostSession.StartAsync(framework, cancellationToken); - Console.WriteLine("[RPC Test] TCP connection established!"); - using var tcpClient = await tcpClientTask; + var discovered = await session.DiscoverAsync(cancellationToken); - await using var stream = tcpClient.GetStream(); + var basicTests = discovered + .Select(x => x.Node) + .Where(node => node.Uid.Contains(".BasicTests.")) + .ToArray(); - Console.WriteLine("[RPC Test] Creating JSON-RPC connection..."); - using var rpc = new JsonRpc(new HeaderDelimitedMessageHandler(stream, stream, new SystemTextJsonFormatter - { - JsonSerializerOptions = RpcJsonSerializerOptions.Default - })); + await Assert.That(basicTests).Count().IsGreaterThan(0); - Console.WriteLine("[RPC Test] Creating TestingPlatformClient..."); - using var client = new TestingPlatformClient(rpc, tcpClient, new ProcessHandle(cliProcess, output), enableDiagnostic: true); + var runUpdates = await session.RunAsync(basicTests, cancellationToken); - Console.WriteLine("[RPC Test] Initializing client..."); - var initTask = client.InitializeAsync(); - var initTimeout = Task.Delay(TimeSpan.FromSeconds(10), cancellationToken); - var cliProcessTask2 = cliProcess.Task; + var executed = runUpdates.Where(x => x.Node.ExecutionState is not null and not "in-progress").ToArray(); + var passed = executed.Count(x => x.Node.ExecutionState == "passed"); - var initCompleted = await Task.WhenAny(cliProcessTask2, initTask, initTimeout); - if (initCompleted == initTimeout) - { - throw new TimeoutException("[RPC Test] Timeout during client initialization after 10 seconds"); - } - if (initCompleted == cliProcessTask2) + using (Assert.Multiple()) { - var result = await cliProcessTask2; - throw new InvalidOperationException($"[RPC Test] Test host exited during initialization with code {result.ExitCode}"); + await Assert.That(executed).Count().IsEqualTo(basicTests.Length); + await Assert.That(passed).IsEqualTo(basicTests.Length); } + } - var initResponse = await initTask; - Console.WriteLine($"[RPC Test] Client initialized successfully. Server info: {initResponse.ServerInfo.Name}"); - - var discoveryId = Guid.NewGuid(); - Console.WriteLine($"[RPC Test] Starting test discovery with ID: {discoveryId}"); - - List discoveredResults = []; - var updateCount = 0; - var discoverTestsResponse = await client.DiscoverTestsAsync(discoveryId, updates => - { - updateCount++; - discoveredResults.AddRange(updates); - Console.WriteLine($"[RPC Test] Received discovery update #{updateCount}: {updates.Length} tests"); - return Task.CompletedTask; - }); - - await Assert.That(discoveredResults).Count().IsGreaterThan(4000); - - Console.WriteLine("[RPC Test] Waiting for discovery to complete..."); - await discoverTestsResponse.WaitCompletionAsync(); - Console.WriteLine($"[RPC Test] Discovery completed. Total updates: {updateCount}, Total tests: {discoveredResults.Count}"); - - var originalDiscovered = discoveredResults.Where(x => x.Node.ExecutionState is "discovered").ToList(); + [Test] + [Timeout(300_000)] + [Retry(3)] + [MethodDataSource(nameof(Frameworks))] + public async Task RunTests_WithSkippedTest_ReportsSkippedState(string framework, CancellationToken cancellationToken) + { + await using var session = await TestHostSession.StartAsync(framework, cancellationToken); - // Apply filter if provided - TestNode[]? testNodes = null; - if (testUidFilter != null) - { - testNodes = originalDiscovered - .Select(x => x.Node) - .Where(testUidFilter) - .ToArray(); + var discovered = await session.DiscoverAsync(cancellationToken); - Console.WriteLine($"[RPC Test] Filtered to {testNodes.Length} tests out of {originalDiscovered.Count}"); - } + var skipTest = discovered + .Select(x => x.Node) + .Where(node => node.Uid.Contains(".SimpleSkipTest.")) + .ToArray(); - discoveredResults.Clear(); - updateCount = 0; - Console.WriteLine($"[RPC Test] Starting test execution with {testNodes?.Length ?? originalDiscovered.Count} tests"); - var executeTestsResponse = await client.RunTestsAsync(discoveryId, updates => - { - updateCount++; - discoveredResults.AddRange(updates); - var inProgress = updates.Count(x => x.Node.ExecutionState == "in-progress"); - var finished = updates.Count(x => x.Node.ExecutionState != "in-progress"); - Console.WriteLine($"[RPC Test] Execution update #{updateCount}: {inProgress} in progress, {finished} finished"); - return Task.CompletedTask; - }, testNodes); + await Assert.That(skipTest).Count().IsGreaterThan(0); - Console.WriteLine("[RPC Test] Waiting for execution to complete..."); - await executeTestsResponse.WaitCompletionAsync(); - Console.WriteLine($"[RPC Test] Execution completed. Total updates: {updateCount}"); + var runUpdates = await session.RunAsync(skipTest, cancellationToken); - var finished = discoveredResults.Where(x => x.Node.ExecutionState is not "in-progress").ToList(); + var skipped = runUpdates + .Where(x => x.Node.ExecutionState == "skipped") + .ToArray(); - using (Assert.Multiple()) - { - if (testUidFilter != null) - { - // With filter, we expect fewer tests - Console.WriteLine($"[RPC Test] With filter, executed test count: {finished.Count}"); - await Assert.That(finished).Count().IsGreaterThan(0); - await Assert.That(finished).Count().IsLessThan(originalDiscovered.Count); - } - else - { - // Without filter, expect all tests - Console.WriteLine($"[RPC Test] Asserting discovered test count: {originalDiscovered.Count} (expected >= 3400)"); - await Assert.That(originalDiscovered).Count().IsGreaterThanOrEqualTo(3400); - } - - Console.WriteLine("[RPC Test] Sending exit command to test host..."); - await client.ExitAsync(); - Console.WriteLine("[RPC Test] Test completed successfully!"); - } + await Assert.That(skipped).Count().IsGreaterThan(0); } }