From 915e13c84227f71835a7455629ec5dc17f15e4cb Mon Sep 17 00:00:00 2001 From: Francesco Renzi Date: Wed, 11 Mar 2026 08:56:08 -0700 Subject: [PATCH] Integrate DAP debugger into JobRunner and StepsRunner --- src/Runner.Worker/JobRunner.cs | 81 ++++ src/Runner.Worker/StepsRunner.cs | 49 ++ src/Test/L0/Worker/DapDebugSessionL0.cs | 611 ++++++++++++++++++++++++ src/Test/L0/Worker/DapMessagesL0.cs | 233 +++++++++ src/Test/L0/Worker/DapServerL0.cs | 170 +++++++ 5 files changed, 1144 insertions(+) create mode 100644 src/Test/L0/Worker/DapDebugSessionL0.cs create mode 100644 src/Test/L0/Worker/DapMessagesL0.cs create mode 100644 src/Test/L0/Worker/DapServerL0.cs diff --git a/src/Runner.Worker/JobRunner.cs b/src/Runner.Worker/JobRunner.cs index 80f9caf6d..cea4771e8 100644 --- a/src/Runner.Worker/JobRunner.cs +++ b/src/Runner.Worker/JobRunner.cs @@ -13,6 +13,7 @@ using GitHub.DistributedTask.WebApi; using GitHub.Runner.Common; using GitHub.Runner.Common.Util; using GitHub.Runner.Sdk; +using GitHub.Runner.Worker.Dap; using GitHub.Services.Common; using GitHub.Services.WebApi; using Sdk.RSWebApi.Contracts; @@ -112,6 +113,9 @@ namespace GitHub.Runner.Worker IExecutionContext jobContext = null; CancellationTokenRegistration? runnerShutdownRegistration = null; + IDapServer dapServer = null; + IDapDebugSession debugSession = null; + CancellationTokenRegistration? dapCancellationRegistration = null; try { // Create the job execution context. @@ -124,6 +128,31 @@ namespace GitHub.Runner.Worker if (jobContext.Global.EnableDebugger) { Trace.Info("Debugger enabled for this job run"); + + try + { + var port = 4711; + var portEnv = Environment.GetEnvironmentVariable("ACTIONS_DAP_PORT"); + if (!string.IsNullOrEmpty(portEnv) && int.TryParse(portEnv, out var customPort)) + { + port = customPort; + } + + dapServer = HostContext.GetService(); + debugSession = HostContext.GetService(); + + dapServer.SetSession(debugSession); + debugSession.SetDapServer(dapServer); + + await dapServer.StartAsync(port, jobRequestCancellationToken); + Trace.Info($"DAP server started on port {port}, listening for debugger client"); + } + catch (Exception ex) + { + Trace.Warning($"Failed to start DAP server: {ex.Message}. Job will continue without debugging."); + dapServer = null; + debugSession = null; + } } runnerShutdownRegistration = HostContext.RunnerShutdownToken.Register(() => @@ -224,6 +253,39 @@ namespace GitHub.Runner.Worker await Task.WhenAny(_jobServerQueue.JobRecordUpdated.Task, Task.Delay(1000)); } + // Wait for DAP debugger client connection and handshake after "Set up job" + // so the job page shows the setup step before we block on the debugger + if (dapServer != null && debugSession != null) + { + try + { + Trace.Info("Waiting for debugger client connection..."); + await dapServer.WaitForConnectionAsync(jobRequestCancellationToken); + Trace.Info("Debugger client connected."); + + await debugSession.WaitForHandshakeAsync(jobRequestCancellationToken); + Trace.Info("DAP handshake complete."); + + dapCancellationRegistration = jobRequestCancellationToken.Register(() => + { + Trace.Info("Job cancellation requested, cancelling debug session."); + debugSession.CancelSession(); + }); + } + catch (OperationCanceledException) when (jobRequestCancellationToken.IsCancellationRequested) + { + Trace.Info("Job was cancelled before debugger client connected. Continuing without debugger."); + dapServer = null; + debugSession = null; + } + catch (Exception ex) + { + Trace.Warning($"Failed to complete DAP handshake: {ex.Message}. Job will continue without debugging."); + dapServer = null; + debugSession = null; + } + } + // Run all job steps Trace.Info("Run all job steps."); var stepsRunner = HostContext.GetService(); @@ -264,6 +326,25 @@ namespace GitHub.Runner.Worker runnerShutdownRegistration = null; } + if (dapCancellationRegistration.HasValue) + { + dapCancellationRegistration.Value.Dispose(); + dapCancellationRegistration = null; + } + + if (dapServer != null) + { + try + { + Trace.Info("Stopping DAP server"); + await dapServer.StopAsync(); + } + catch (Exception ex) + { + Trace.Warning($"Error stopping DAP server: {ex.Message}"); + } + } + await ShutdownQueue(throwOnFailure: false); } } diff --git a/src/Runner.Worker/StepsRunner.cs b/src/Runner.Worker/StepsRunner.cs index 83ce87f64..1c4894cb8 100644 --- a/src/Runner.Worker/StepsRunner.cs +++ b/src/Runner.Worker/StepsRunner.cs @@ -10,6 +10,7 @@ using GitHub.DistributedTask.WebApi; using GitHub.Runner.Common; using GitHub.Runner.Common.Util; using GitHub.Runner.Sdk; +using GitHub.Runner.Worker.Dap; using GitHub.Runner.Worker.Expressions; namespace GitHub.Runner.Worker @@ -50,6 +51,16 @@ namespace GitHub.Runner.Worker jobContext.JobContext.Status = (jobContext.Result ?? TaskResult.Succeeded).ToActionResult(); var scopeInputs = new Dictionary(StringComparer.OrdinalIgnoreCase); bool checkPostJobActions = false; + IDapDebugSession debugSession = null; + try + { + debugSession = HostContext.GetService(); + } + catch + { + // Debug session not available — continue without debugging + } + bool isFirstStep = true; while (jobContext.JobSteps.Count > 0 || !checkPostJobActions) { if (jobContext.JobSteps.Count == 0 && !checkPostJobActions) @@ -226,9 +237,35 @@ namespace GitHub.Runner.Worker } else { + // Pause for DAP debugger before step execution + if (debugSession?.IsActive == true) + { + try + { + await debugSession.OnStepStartingAsync(step, jobContext, isFirstStep, jobContext.CancellationToken); + isFirstStep = false; + } + catch (Exception ex) + { + Trace.Warning($"DAP OnStepStarting error: {ex.Message}"); + } + } + // Run the step await RunStepAsync(step, jobContext.CancellationToken); CompleteStep(step); + + if (debugSession?.IsActive == true) + { + try + { + debugSession.OnStepCompleted(step); + } + catch (Exception ex) + { + Trace.Warning($"DAP OnStepCompleted error: {ex.Message}"); + } + } } } finally @@ -255,6 +292,18 @@ namespace GitHub.Runner.Worker Trace.Info($"Current state: job state = '{jobContext.Result}'"); } + + if (debugSession?.IsActive == true) + { + try + { + debugSession.OnJobCompleted(); + } + catch (Exception ex) + { + Trace.Warning($"DAP OnJobCompleted error: {ex.Message}"); + } + } } private async Task RunStepAsync(IStep step, CancellationToken jobCancellationToken) diff --git a/src/Test/L0/Worker/DapDebugSessionL0.cs b/src/Test/L0/Worker/DapDebugSessionL0.cs new file mode 100644 index 000000000..ffff047b5 --- /dev/null +++ b/src/Test/L0/Worker/DapDebugSessionL0.cs @@ -0,0 +1,611 @@ +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using GitHub.DistributedTask.ObjectTemplating.Tokens; +using GitHub.DistributedTask.Pipelines.ContextData; +using GitHub.DistributedTask.WebApi; +using GitHub.Runner.Worker; +using GitHub.Runner.Worker.Dap; +using Moq; +using Newtonsoft.Json; +using Xunit; + +namespace GitHub.Runner.Common.Tests.Worker +{ + public sealed class DapDebugSessionL0 + { + private DapDebugSession _session; + private Mock _mockServer; + private List _sentEvents; + private List _sentResponses; + + private TestHostContext CreateTestContext([CallerMemberName] string testName = "") + { + var hc = new TestHostContext(this, testName); + + _session = new DapDebugSession(); + _session.Initialize(hc); + + _sentEvents = new List(); + _sentResponses = new List(); + + _mockServer = new Mock(); + _mockServer.Setup(x => x.SendEvent(It.IsAny())) + .Callback(e => _sentEvents.Add(e)); + _mockServer.Setup(x => x.SendResponse(It.IsAny())) + .Callback(r => _sentResponses.Add(r)); + + _session.SetDapServer(_mockServer.Object); + + return hc; + } + + private Mock CreateMockStep(string displayName, TaskResult? result = null) + { + var mockEc = new Mock(); + mockEc.SetupAllProperties(); + mockEc.Object.Result = result; + + var mockStep = new Mock(); + mockStep.Setup(x => x.DisplayName).Returns(displayName); + mockStep.Setup(x => x.ExecutionContext).Returns(mockEc.Object); + + return mockStep; + } + + private Mock CreateMockJobContext() + { + var mockJobContext = new Mock(); + mockJobContext.Setup(x => x.GetGitHubContext("job")).Returns("test-job"); + return mockJobContext; + } + + private async Task InitializeSessionAsync() + { + var initJson = JsonConvert.SerializeObject(new Request + { + Seq = 1, + Type = "request", + Command = "initialize" + }); + await _session.HandleMessageAsync(initJson, CancellationToken.None); + + var attachJson = JsonConvert.SerializeObject(new Request + { + Seq = 2, + Type = "request", + Command = "attach" + }); + await _session.HandleMessageAsync(attachJson, CancellationToken.None); + + var configJson = JsonConvert.SerializeObject(new Request + { + Seq = 3, + Type = "request", + Command = "configurationDone" + }); + await _session.HandleMessageAsync(configJson, CancellationToken.None); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void InitialStateIsWaitingForConnection() + { + using (CreateTestContext()) + { + Assert.Equal(DapSessionState.WaitingForConnection, _session.State); + Assert.False(_session.IsActive); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task InitializeHandlerSetsInitializingState() + { + using (CreateTestContext()) + { + var json = JsonConvert.SerializeObject(new Request + { + Seq = 1, + Type = "request", + Command = "initialize" + }); + + await _session.HandleMessageAsync(json, CancellationToken.None); + + Assert.Equal(DapSessionState.Initializing, _session.State); + Assert.Single(_sentResponses); + Assert.True(_sentResponses[0].Success); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task ConfigurationDoneSetsReadyState() + { + using (CreateTestContext()) + { + await InitializeSessionAsync(); + + Assert.Equal(DapSessionState.Ready, _session.State); + Assert.True(_session.IsActive); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task OnStepStartingPausesAndSendsStoppedEvent() + { + using (CreateTestContext()) + { + await InitializeSessionAsync(); + _session.HandleClientConnected(); + + // Wait for the async initialized event to arrive, then clear + await Task.Delay(200); + _sentEvents.Clear(); + + var step = CreateMockStep("Checkout code"); + var jobContext = CreateMockJobContext(); + + var cts = new CancellationTokenSource(); + var stepTask = _session.OnStepStartingAsync(step.Object, jobContext.Object, isFirstStep: true, cts.Token); + + await Task.Delay(100); + Assert.False(stepTask.IsCompleted); + Assert.Equal(DapSessionState.Paused, _session.State); + + var stoppedEvents = _sentEvents.FindAll(e => e.EventType == "stopped"); + Assert.Single(stoppedEvents); + + var continueJson = JsonConvert.SerializeObject(new Request + { + Seq = 10, + Type = "request", + Command = "continue" + }); + await _session.HandleMessageAsync(continueJson, CancellationToken.None); + + await Task.WhenAny(stepTask, Task.Delay(5000)); + Assert.True(stepTask.IsCompleted); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task NextCommandPausesOnFollowingStep() + { + using (CreateTestContext()) + { + await InitializeSessionAsync(); + _session.HandleClientConnected(); + _sentEvents.Clear(); + + var step1 = CreateMockStep("Step 1"); + var jobContext = CreateMockJobContext(); + + var step1Task = _session.OnStepStartingAsync(step1.Object, jobContext.Object, isFirstStep: true, CancellationToken.None); + + var nextJson = JsonConvert.SerializeObject(new Request + { + Seq = 10, + Type = "request", + Command = "next" + }); + await _session.HandleMessageAsync(nextJson, CancellationToken.None); + await Task.WhenAny(step1Task, Task.Delay(5000)); + Assert.True(step1Task.IsCompleted); + + _session.OnStepCompleted(step1.Object); + _sentEvents.Clear(); + + var step2 = CreateMockStep("Step 2"); + var step2Task = _session.OnStepStartingAsync(step2.Object, jobContext.Object, isFirstStep: false, CancellationToken.None); + + await Task.Delay(100); + Assert.False(step2Task.IsCompleted); + Assert.Equal(DapSessionState.Paused, _session.State); + + var stoppedEvents = _sentEvents.FindAll(e => e.EventType == "stopped"); + Assert.Single(stoppedEvents); + + var continueJson = JsonConvert.SerializeObject(new Request + { + Seq = 11, + Type = "request", + Command = "continue" + }); + await _session.HandleMessageAsync(continueJson, CancellationToken.None); + await Task.WhenAny(step2Task, Task.Delay(5000)); + Assert.True(step2Task.IsCompleted); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task ContinueCommandSkipsNextPause() + { + using (CreateTestContext()) + { + await InitializeSessionAsync(); + _session.HandleClientConnected(); + _sentEvents.Clear(); + + var step1 = CreateMockStep("Step 1"); + var jobContext = CreateMockJobContext(); + + var step1Task = _session.OnStepStartingAsync(step1.Object, jobContext.Object, isFirstStep: true, CancellationToken.None); + + var continueJson = JsonConvert.SerializeObject(new Request + { + Seq = 10, + Type = "request", + Command = "continue" + }); + await _session.HandleMessageAsync(continueJson, CancellationToken.None); + await Task.WhenAny(step1Task, Task.Delay(5000)); + Assert.True(step1Task.IsCompleted); + + _session.OnStepCompleted(step1.Object); + _sentEvents.Clear(); + + var step2 = CreateMockStep("Step 2"); + var step2Task = _session.OnStepStartingAsync(step2.Object, jobContext.Object, isFirstStep: false, CancellationToken.None); + + await Task.WhenAny(step2Task, Task.Delay(5000)); + Assert.True(step2Task.IsCompleted); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task CancellationUnblocksPausedStep() + { + using (CreateTestContext()) + { + await InitializeSessionAsync(); + _session.HandleClientConnected(); + _sentEvents.Clear(); + + var step = CreateMockStep("Step 1"); + var jobContext = CreateMockJobContext(); + + var cts = new CancellationTokenSource(); + var stepTask = _session.OnStepStartingAsync(step.Object, jobContext.Object, isFirstStep: true, cts.Token); + + await Task.Delay(100); + Assert.False(stepTask.IsCompleted); + Assert.Equal(DapSessionState.Paused, _session.State); + + cts.Cancel(); + + await Task.WhenAny(stepTask, Task.Delay(5000)); + Assert.True(stepTask.IsCompleted); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task CancelSessionSendsTerminatedAndExitedEvents() + { + using (CreateTestContext()) + { + await InitializeSessionAsync(); + _sentEvents.Clear(); + + _session.CancelSession(); + + Assert.Equal(DapSessionState.Terminated, _session.State); + Assert.False(_session.IsActive); + + var terminatedEvents = _sentEvents.FindAll(e => e.EventType == "terminated"); + var exitedEvents = _sentEvents.FindAll(e => e.EventType == "exited"); + Assert.Single(terminatedEvents); + Assert.Single(exitedEvents); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task CancelSessionReleasesBlockedStep() + { + using (CreateTestContext()) + { + await InitializeSessionAsync(); + _session.HandleClientConnected(); + _sentEvents.Clear(); + + var step = CreateMockStep("Blocked Step"); + var jobContext = CreateMockJobContext(); + + var stepTask = _session.OnStepStartingAsync(step.Object, jobContext.Object, isFirstStep: true, CancellationToken.None); + + await Task.Delay(100); + Assert.False(stepTask.IsCompleted); + + _session.CancelSession(); + + await Task.WhenAny(stepTask, Task.Delay(5000)); + Assert.True(stepTask.IsCompleted); + Assert.Equal(DapSessionState.Terminated, _session.State); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task ReconnectionResendStoppedEvent() + { + using (CreateTestContext()) + { + await InitializeSessionAsync(); + _session.HandleClientConnected(); + + // Wait for the async initialized event to arrive, then clear + await Task.Delay(200); + _sentEvents.Clear(); + + var step = CreateMockStep("Step 1"); + var jobContext = CreateMockJobContext(); + + var stepTask = _session.OnStepStartingAsync(step.Object, jobContext.Object, isFirstStep: true, CancellationToken.None); + + await Task.Delay(100); + Assert.Equal(DapSessionState.Paused, _session.State); + var stoppedEvents = _sentEvents.FindAll(e => e.EventType == "stopped"); + Assert.Single(stoppedEvents); + + _session.HandleClientDisconnected(); + Assert.Equal(DapSessionState.Paused, _session.State); + + _sentEvents.Clear(); + _session.HandleClientConnected(); + + Assert.Single(_sentEvents); + Assert.Equal("stopped", _sentEvents[0].EventType); + + var continueJson = JsonConvert.SerializeObject(new Request + { + Seq = 20, + Type = "request", + Command = "continue" + }); + await _session.HandleMessageAsync(continueJson, CancellationToken.None); + await Task.WhenAny(stepTask, Task.Delay(5000)); + Assert.True(stepTask.IsCompleted); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task DisconnectCommandTerminatesSession() + { + using (CreateTestContext()) + { + await InitializeSessionAsync(); + + var disconnectJson = JsonConvert.SerializeObject(new Request + { + Seq = 10, + Type = "request", + Command = "disconnect" + }); + await _session.HandleMessageAsync(disconnectJson, CancellationToken.None); + + Assert.Equal(DapSessionState.Terminated, _session.State); + Assert.False(_session.IsActive); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task OnStepCompletedTracksCompletedSteps() + { + using (CreateTestContext()) + { + await InitializeSessionAsync(); + _session.HandleClientConnected(); + + var step1 = CreateMockStep("Step 1"); + step1.Object.ExecutionContext.Result = TaskResult.Succeeded; + var jobContext = CreateMockJobContext(); + + var step1Task = _session.OnStepStartingAsync(step1.Object, jobContext.Object, isFirstStep: true, CancellationToken.None); + + var continueJson = JsonConvert.SerializeObject(new Request + { + Seq = 10, + Type = "request", + Command = "continue" + }); + await _session.HandleMessageAsync(continueJson, CancellationToken.None); + await Task.WhenAny(step1Task, Task.Delay(5000)); + + _session.OnStepCompleted(step1.Object); + + var stackTraceJson = JsonConvert.SerializeObject(new Request + { + Seq = 11, + Type = "request", + Command = "stackTrace" + }); + _sentResponses.Clear(); + await _session.HandleMessageAsync(stackTraceJson, CancellationToken.None); + + Assert.Single(_sentResponses); + Assert.True(_sentResponses[0].Success); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task OnJobCompletedSendsTerminatedAndExitedEvents() + { + using (CreateTestContext()) + { + await InitializeSessionAsync(); + _sentEvents.Clear(); + + _session.OnJobCompleted(); + + Assert.Equal(DapSessionState.Terminated, _session.State); + + var terminatedEvents = _sentEvents.FindAll(e => e.EventType == "terminated"); + var exitedEvents = _sentEvents.FindAll(e => e.EventType == "exited"); + Assert.Single(terminatedEvents); + Assert.Single(exitedEvents); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task OnStepStartingNoOpWhenNotActive() + { + using (CreateTestContext()) + { + var step = CreateMockStep("Step 1"); + var jobContext = CreateMockJobContext(); + + var task = _session.OnStepStartingAsync(step.Object, jobContext.Object, isFirstStep: true, CancellationToken.None); + + await Task.WhenAny(task, Task.Delay(5000)); + Assert.True(task.IsCompleted); + + _mockServer.Verify(x => x.SendEvent(It.IsAny()), Times.Never); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task ThreadsCommandReturnsJobThread() + { + using (CreateTestContext()) + { + await InitializeSessionAsync(); + + var threadsJson = JsonConvert.SerializeObject(new Request + { + Seq = 10, + Type = "request", + Command = "threads" + }); + _sentResponses.Clear(); + await _session.HandleMessageAsync(threadsJson, CancellationToken.None); + + Assert.Single(_sentResponses); + Assert.True(_sentResponses[0].Success); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task UnsupportedCommandReturnsErrorResponse() + { + using (CreateTestContext()) + { + await InitializeSessionAsync(); + + var json = JsonConvert.SerializeObject(new Request + { + Seq = 99, + Type = "request", + Command = "stepIn" + }); + _sentResponses.Clear(); + await _session.HandleMessageAsync(json, CancellationToken.None); + + Assert.Single(_sentResponses); + Assert.False(_sentResponses[0].Success); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task FullFlowInitAttachConfigStepContinueComplete() + { + using (CreateTestContext()) + { + await InitializeSessionAsync(); + _session.HandleClientConnected(); + _sentEvents.Clear(); + _sentResponses.Clear(); + + Assert.Equal(DapSessionState.Ready, _session.State); + + var step = CreateMockStep("Run tests"); + var jobContext = CreateMockJobContext(); + + var stepTask = _session.OnStepStartingAsync(step.Object, jobContext.Object, isFirstStep: true, CancellationToken.None); + + await Task.Delay(100); + Assert.Equal(DapSessionState.Paused, _session.State); + + var stoppedEvents = _sentEvents.FindAll(e => e.EventType == "stopped"); + Assert.Single(stoppedEvents); + + var continueJson = JsonConvert.SerializeObject(new Request + { + Seq = 10, + Type = "request", + Command = "continue" + }); + await _session.HandleMessageAsync(continueJson, CancellationToken.None); + await Task.WhenAny(stepTask, Task.Delay(5000)); + Assert.True(stepTask.IsCompleted); + + var continuedEvents = _sentEvents.FindAll(e => e.EventType == "continued"); + Assert.Single(continuedEvents); + + step.Object.ExecutionContext.Result = TaskResult.Succeeded; + _session.OnStepCompleted(step.Object); + + _sentEvents.Clear(); + _session.OnJobCompleted(); + + Assert.Equal(DapSessionState.Terminated, _session.State); + var terminatedEvents = _sentEvents.FindAll(e => e.EventType == "terminated"); + var exitedEvents = _sentEvents.FindAll(e => e.EventType == "exited"); + Assert.Single(terminatedEvents); + Assert.Single(exitedEvents); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task DoubleCancelSessionIsIdempotent() + { + using (CreateTestContext()) + { + await InitializeSessionAsync(); + _sentEvents.Clear(); + + _session.CancelSession(); + _session.CancelSession(); + + Assert.Equal(DapSessionState.Terminated, _session.State); + + var terminatedEvents = _sentEvents.FindAll(e => e.EventType == "terminated"); + Assert.Single(terminatedEvents); + } + } + } +} diff --git a/src/Test/L0/Worker/DapMessagesL0.cs b/src/Test/L0/Worker/DapMessagesL0.cs new file mode 100644 index 000000000..1b8285717 --- /dev/null +++ b/src/Test/L0/Worker/DapMessagesL0.cs @@ -0,0 +1,233 @@ +using System.Collections.Generic; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Xunit; +using GitHub.Runner.Worker.Dap; + +namespace GitHub.Runner.Common.Tests.Worker +{ + public sealed class DapMessagesL0 + { + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void RequestSerializesCorrectly() + { + var request = new Request + { + Seq = 1, + Type = "request", + Command = "initialize", + Arguments = JObject.FromObject(new { clientID = "test-client" }) + }; + + var json = JsonConvert.SerializeObject(request); + var deserialized = JsonConvert.DeserializeObject(json); + + Assert.Equal(1, deserialized.Seq); + Assert.Equal("request", deserialized.Type); + Assert.Equal("initialize", deserialized.Command); + Assert.Equal("test-client", deserialized.Arguments["clientID"].ToString()); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void ResponseSerializesCorrectly() + { + var response = new Response + { + Seq = 2, + Type = "response", + RequestSeq = 1, + Success = true, + Command = "initialize", + Body = new Capabilities { SupportsConfigurationDoneRequest = true } + }; + + var json = JsonConvert.SerializeObject(response); + var deserialized = JsonConvert.DeserializeObject(json); + + Assert.Equal(2, deserialized.Seq); + Assert.Equal("response", deserialized.Type); + Assert.Equal(1, deserialized.RequestSeq); + Assert.True(deserialized.Success); + Assert.Equal("initialize", deserialized.Command); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void EventSerializesWithCorrectType() + { + var evt = new Event + { + EventType = "stopped", + Body = new StoppedEventBody + { + Reason = "entry", + Description = "Stopped at entry", + ThreadId = 1, + AllThreadsStopped = true + } + }; + + Assert.Equal("event", evt.Type); + + var json = JsonConvert.SerializeObject(evt); + Assert.Contains("\"type\":\"event\"", json); + Assert.Contains("\"event\":\"stopped\"", json); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void StoppedEventBodyOmitsNullFields() + { + var body = new StoppedEventBody + { + Reason = "step" + }; + + var json = JsonConvert.SerializeObject(body); + Assert.Contains("\"reason\":\"step\"", json); + Assert.DoesNotContain("\"threadId\"", json); + Assert.DoesNotContain("\"allThreadsStopped\"", json); + Assert.DoesNotContain("\"description\"", json); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void CapabilitiesMvpDefaults() + { + var caps = new Capabilities + { + SupportsConfigurationDoneRequest = true, + SupportsFunctionBreakpoints = false, + SupportsStepBack = false + }; + + var json = JsonConvert.SerializeObject(caps); + var deserialized = JsonConvert.DeserializeObject(json); + + Assert.True(deserialized.SupportsConfigurationDoneRequest); + Assert.False(deserialized.SupportsFunctionBreakpoints); + Assert.False(deserialized.SupportsStepBack); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void ContinueResponseBodySerialization() + { + var body = new ContinueResponseBody { AllThreadsContinued = true }; + var json = JsonConvert.SerializeObject(body); + var deserialized = JsonConvert.DeserializeObject(json); + + Assert.True(deserialized.AllThreadsContinued); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void ThreadsResponseBodySerialization() + { + var body = new ThreadsResponseBody + { + Threads = new List + { + new Thread { Id = 1, Name = "Job Thread" } + } + }; + + var json = JsonConvert.SerializeObject(body); + var deserialized = JsonConvert.DeserializeObject(json); + + Assert.Single(deserialized.Threads); + Assert.Equal(1, deserialized.Threads[0].Id); + Assert.Equal("Job Thread", deserialized.Threads[0].Name); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void StackFrameSerialization() + { + var frame = new StackFrame + { + Id = 1, + Name = "Step: Checkout", + Line = 1, + Column = 1, + PresentationHint = "normal" + }; + + var json = JsonConvert.SerializeObject(frame); + var deserialized = JsonConvert.DeserializeObject(json); + + Assert.Equal(1, deserialized.Id); + Assert.Equal("Step: Checkout", deserialized.Name); + Assert.Equal("normal", deserialized.PresentationHint); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void ExitedEventBodySerialization() + { + var body = new ExitedEventBody { ExitCode = 130 }; + var json = JsonConvert.SerializeObject(body); + var deserialized = JsonConvert.DeserializeObject(json); + + Assert.Equal(130, deserialized.ExitCode); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void DapCommandEnumValues() + { + Assert.Equal(0, (int)DapCommand.Continue); + Assert.Equal(1, (int)DapCommand.Next); + Assert.Equal(4, (int)DapCommand.Disconnect); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void RequestDeserializesFromRawJson() + { + var json = @"{""seq"":5,""type"":""request"",""command"":""continue"",""arguments"":{""threadId"":1}}"; + var request = JsonConvert.DeserializeObject(json); + + Assert.Equal(5, request.Seq); + Assert.Equal("request", request.Type); + Assert.Equal("continue", request.Command); + Assert.Equal(1, request.Arguments["threadId"].Value()); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void ErrorResponseBodySerialization() + { + var body = new ErrorResponseBody + { + Error = new Message + { + Id = 1, + Format = "Something went wrong", + ShowUser = true + } + }; + + var json = JsonConvert.SerializeObject(body); + var deserialized = JsonConvert.DeserializeObject(json); + + Assert.Equal(1, deserialized.Error.Id); + Assert.Equal("Something went wrong", deserialized.Error.Format); + Assert.True(deserialized.Error.ShowUser); + } + } +} diff --git a/src/Test/L0/Worker/DapServerL0.cs b/src/Test/L0/Worker/DapServerL0.cs new file mode 100644 index 000000000..ffda39465 --- /dev/null +++ b/src/Test/L0/Worker/DapServerL0.cs @@ -0,0 +1,170 @@ +using System; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using GitHub.Runner.Worker.Dap; +using Moq; +using Xunit; + +namespace GitHub.Runner.Common.Tests.Worker +{ + public sealed class DapServerL0 + { + private DapServer _server; + + private TestHostContext CreateTestContext([CallerMemberName] string testName = "") + { + var hc = new TestHostContext(this, testName); + _server = new DapServer(); + _server.Initialize(hc); + return hc; + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void InitializeSucceeds() + { + using (CreateTestContext()) + { + Assert.NotNull(_server); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void SetSessionAcceptsMock() + { + using (CreateTestContext()) + { + var mockSession = new Mock(); + _server.SetSession(mockSession.Object); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void SendEventNoClientDoesNotThrow() + { + using (CreateTestContext()) + { + var evt = new Event + { + EventType = "stopped", + Body = new StoppedEventBody + { + Reason = "entry", + ThreadId = 1, + AllThreadsStopped = true + } + }; + + _server.SendEvent(evt); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void SendResponseNoClientDoesNotThrow() + { + using (CreateTestContext()) + { + var response = new Response + { + Type = "response", + RequestSeq = 1, + Command = "initialize", + Success = true + }; + + _server.SendResponse(response); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void SendMessageNoClientDoesNotThrow() + { + using (CreateTestContext()) + { + var msg = new ProtocolMessage + { + Type = "response", + Seq = 1 + }; + + _server.SendMessage(msg); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task StopWithoutStartDoesNotThrow() + { + using (CreateTestContext()) + { + await _server.StopAsync(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task StartAndStopOnAvailablePort() + { + using (CreateTestContext()) + { + var cts = new CancellationTokenSource(); + await _server.StartAsync(0, cts.Token); + await _server.StopAsync(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task WaitForConnectionCancelledByCancellationToken() + { + using (CreateTestContext()) + { + var cts = new CancellationTokenSource(); + await _server.StartAsync(0, cts.Token); + + var waitTask = _server.WaitForConnectionAsync(cts.Token); + + cts.Cancel(); + + await Assert.ThrowsAnyAsync(async () => + { + await waitTask; + }); + + await _server.StopAsync(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task StartAndStopMultipleTimesDoesNotThrow() + { + using (CreateTestContext()) + { + var cts1 = new CancellationTokenSource(); + await _server.StartAsync(0, cts1.Token); + await _server.StopAsync(); + + _server = new DapServer(); + _server.Initialize(CreateTestContext()); + var cts2 = new CancellationTokenSource(); + await _server.StartAsync(0, cts2.Token); + await _server.StopAsync(); + } + } + } +}