diff --git a/src/Runner.Worker/Dap/DapDebugger.cs b/src/Runner.Worker/Dap/DapDebugger.cs index d5dba2fe2..7cecd6281 100644 --- a/src/Runner.Worker/Dap/DapDebugger.cs +++ b/src/Runner.Worker/Dap/DapDebugger.cs @@ -16,19 +16,10 @@ using Microsoft.DevTunnels.Connections; using Microsoft.DevTunnels.Contracts; using Microsoft.DevTunnels.Management; using Newtonsoft.Json; +using Pipelines = GitHub.DistributedTask.Pipelines; namespace GitHub.Runner.Worker.Dap { - /// - /// Stores information about a completed step for stack trace display. - /// - internal sealed class CompletedStepInfo - { - public string DisplayName { get; set; } - public TaskResult? Result { get; set; } - public int FrameId { get; set; } - } - /// /// Single public facade for the Debug Adapter Protocol subsystem. /// Owns the full transport, handshake, step-level pauses, variable @@ -51,8 +42,12 @@ namespace GitHub.Runner.Worker.Dap // Frame ID for the current step (always 1) private const int _currentFrameId = 1; - // Frame IDs for completed steps start at 1000 - private const int _completedFrameIdBase = 1000; + // Frame ID for the static "job" frame anchored at line 1 of the execution view. + private const int _jobFrameId = 2; + + // MVP serves a single synthesized source per session (the job's execution view). + // Stable session-scoped ID 1; future sources (composite step-in) will use higher IDs. + private const int _executionViewSourceReference = 1; private TcpListener _listener; private TcpClient _client; @@ -93,11 +88,6 @@ namespace GitHub.Runner.Worker.Dap // Current execution context private IStep _currentStep; private IExecutionContext _jobContext; - private int _currentStepIndex; - - // Track completed steps for stack trace - private readonly List _completedSteps = new List(); - private int _nextCompletedFrameId = _completedFrameIdBase; // Client connection tracking for reconnection support private volatile bool _isClientConnected; @@ -108,6 +98,8 @@ namespace GitHub.Runner.Worker.Dap // REPL command executor for run() commands private DapReplExecutor _replExecutor; + private JobExecutionView _executionView; + public bool IsActive => _state == DapSessionState.Ready || _state == DapSessionState.Paused || @@ -260,7 +252,8 @@ namespace GitHub.Runner.Worker.Dap } catch (Exception ex) { - Trace.Warning($"DAP job-completed pause error: {ex.Message}"); + Trace.Warning("DAP job-completed pause error."); + Trace.Error(ex); } } @@ -270,7 +263,8 @@ namespace GitHub.Runner.Worker.Dap } catch (Exception ex) { - Trace.Warning($"DAP OnJobCompleted error: {ex.Message}"); + Trace.Warning("DAP OnJobCompleted error."); + Trace.Error(ex); } } } @@ -387,7 +381,8 @@ namespace GitHub.Runner.Worker.Dap } catch (Exception ex) { - Trace.Warning($"DAP OnStepStarting error: {ex.Message}"); + Trace.Warning("DAP OnStepStarting error."); + Trace.Error(ex); } } @@ -400,10 +395,8 @@ namespace GitHub.Runner.Worker.Dap try { - var result = step.ExecutionContext?.Result; Trace.Info("Step completed"); - - // Add to completed steps list for stack trace + JobExecutionView view; lock (_stateLock) { if (_state != DapSessionState.Ready && @@ -413,20 +406,353 @@ namespace GitHub.Runner.Worker.Dap return; } - _completedSteps.Add(new CompletedStepInfo + // Clear current-step ref if it matches; otherwise leave alone + // (defensive — OnStepStartingAsync may have already advanced it). + if (ReferenceEquals(_currentStep, step)) { - DisplayName = step.DisplayName, - Result = result, - FrameId = _nextCompletedFrameId++ - }); + _currentStep = null; + } + view = _executionView; + } + + // If the skipped step was a Main IActionRunner with a predicted + // Post-step placeholder, mark that placeholder as skipped so + // the view does not advertise a step that will never run. + if (view != null && + step is IActionRunner actionRunner && + actionRunner.Stage == ActionRunStage.Main && + actionRunner.Action != null && + step.ExecutionContext?.Result == TaskResult.Skipped) + { + var matchKey = MatchKeyFor(actionRunner.Action.Id); + if (view.TryMarkSkipped(matchKey)) + { + SendLoadedSourceEvent("changed"); + } } } catch (Exception ex) { - Trace.Warning($"DAP OnStepCompleted error: {ex.Message}"); + Trace.Warning("DAP OnStepCompleted error."); + Trace.Error(ex); } } + /// + /// Snapshot of the current job execution view, or null if it has not + /// been built yet (debugger inactive, or InitializeJob has not yet + /// signalled). Phase 2c will consume this for DAP source/stack-trace + /// responses. + /// + internal JobExecutionView ExecutionView + { + get + { + lock (_stateLock) + { + return _executionView; + } + } + } + + public async Task OnJobStepsInitializedAsync(IEnumerable mainQueue, IEnumerable initialPostStack) + { + if (!IsActive) + { + return; + } + + try + { + IExecutionContext jobContext; + lock (_stateLock) + { + jobContext = _jobContext; + } + + string jobId = jobContext?.GetGitHubContext("job"); + if (string.IsNullOrWhiteSpace(jobId)) + { + jobId = "job"; + } + + // Materialize mainQueue once so we can iterate it twice + // (once for entries, once for the post-step predictor). + var mainSteps = mainQueue == null ? new List() : new List(mainQueue); + + var entries = new List<(JobExecutionViewEntry entry, IStep stepIdentity)>(); + foreach (var step in mainSteps) + { + var entry = StepEntryTranslator.TryTranslate(step); + if (entry != null) + { + entries.Add((entry, step)); + } + } + // Stack.GetEnumerator() yields items in LIFO order — the + // same order callers will pop them. We materialize them into + // the view in that pop order so post-step entries appear in + // execution order. + if (initialPostStack != null) + { + foreach (var step in initialPostStack) + { + var entry = StepEntryTranslator.TryTranslate(step); + if (entry != null) + { + entries.Add((entry, step)); + } + } + } + + var view = new JobExecutionView(jobId); + if (entries.Count > 0) + { + view.AppendRange(entries); + } + + // Predict Post-step placeholders for actions that declare + // HasPost in their action manifest. Walking Pre+Main runners + // in declaration order, then prepending each prediction so + // the rendered post section matches the runner's LIFO + // post-execution order (the runner's post stack pops in + // reverse-registration order). Wrapped in a try/catch so a + // missing IActionManager or LoadAction failure cannot + // prevent the view from being published. + try + { + PredictPostPlaceholders(jobContext, mainSteps, view); + } + catch (Exception ex) + { + Trace.Warning("DAP predictor: predicting post placeholders failed; continuing without predictions."); + Trace.Error(ex); + } + + lock (_stateLock) + { + _executionView = view; + } + + Trace.Info($"DAP execution view initialized with {view.EntryCount} entries."); + SendLoadedSourceEvent("new"); + } + catch (Exception ex) + { + Trace.Warning("DAP OnJobStepsInitialized error."); + Trace.Error(ex); + } + + await Task.CompletedTask; + } + + public void OnPostStepRegistered(IStep step) + { + if (!IsActive || step == null) + { + return; + } + + try + { + JobExecutionView view; + lock (_stateLock) + { + view = _executionView; + } + + if (view == null) + { + return; + } + + // Try to claim a previously-predicted placeholder. When + // OnJobStepsInitializedAsync ran, we walked the Pre+Main + // queue and synthesized a Post placeholder for every action + // whose manifest declared HasPost. If this registration + // matches one of those placeholders by Action.Id, claim it + // in place — no view growth, no `loadedSource changed` + // event needed. + if (step is IActionRunner postRunner && postRunner.Action != null) + { + var matchKey = MatchKeyFor(postRunner.Action.Id); + if (view.TryClaim(matchKey, step).HasValue) + { + return; + } + } + + // Unpredicted path: composite-action JIT post discovery, + // container hooks, or any other registration we did not + // foresee at view-build time. Fall back to append + notify + // clients via `loadedSource changed`. + var entry = StepEntryTranslator.TryTranslate(step); + if (entry == null) + { + return; + } + + try + { + view.Append(entry, step); + SendLoadedSourceEvent("changed"); + } + catch (InvalidOperationException ex) + { + // Step already registered — RegisterPostJobStep tolerates + // duplicate registrations in some workflow shapes; mirror + // that semantics here so we don't propagate. + Trace.Info($"DAP OnPostStepRegistered: duplicate step ignored ({ex.Message})."); + } + } + catch (Exception ex) + { + Trace.Warning("DAP OnPostStepRegistered error."); + Trace.Error(ex); + } + } + + /// + /// Walks (the queue of Pre+Main + /// IActionRunners produced by JobRunner) and synthesizes a Post + /// placeholder entry on for every action + /// whose manifest declares HasPost = true. + /// + /// Conditions mirror ActionRunner.RunAsync exactly: + /// the runner is in Pre or Main stage, the action is a + /// that is NOT the + /// self-repository alias, the action is not a script, and the + /// resolved reports HasPost. + /// + /// Predictions are collected in declaration order, then APPENDED + /// in reverse so the rendered post section mirrors the runner's + /// LIFO post-execution order (the runner's post stack pops in + /// reverse-registration order — see ExecutionContext.RegisterPostJobStep). + /// + private void PredictPostPlaceholders(IExecutionContext jobContext, IReadOnlyList mainSteps, JobExecutionView view) + { + if (jobContext == null || mainSteps == null || mainSteps.Count == 0 || view == null) + { + return; + } + + IActionManager actionManager; + try + { + actionManager = HostContext.GetService(); + } + catch (Exception ex) + { + Trace.Info($"DAP predictor: IActionManager unavailable ({ex.Message}); skipping post-step prediction."); + return; + } + + var predictions = new List<(JobExecutionViewEntry entry, string matchKey)>(); + var seenActionIds = new HashSet(); + + foreach (var step in mainSteps) + { + if (step is not IActionRunner runner) + { + continue; + } + if (runner.Stage == ActionRunStage.Post) + { + // Post entries are already seeded from initialPostStack. + continue; + } + var action = runner.Action; + if (action == null) + { + continue; + } + // ActionRunner.cs:113 — Post only created when the action is + // a RepositoryPathReference that is not the self-repository + // alias and not a script. + if (action.Reference is not Pipelines.RepositoryPathReference repoRef) + { + continue; + } + if (string.Equals(repoRef.RepositoryType, Pipelines.PipelineConstants.SelfAlias, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + // (A RepositoryPathReference is never a script — script + // run-steps surface as a different ActionStepDefinitionReference + // subclass, so the cast above already filtered them out.) + // Dedupe by Action.Id: the runtime dedups via + // Root.StepsWithPostRegistered.Add(actionRunner.Action.Id), + // so two steps referencing the same Action.Id only ever + // register one Post. Mirror that here so we don't synthesize + // two placeholders for one future registration. + if (!seenActionIds.Add(action.Id)) + { + continue; + } + + Definition definition; + try + { + definition = actionManager.LoadAction(jobContext, action); + } + catch (Exception ex) + { + Trace.Info($"DAP predictor: LoadAction failed for {repoRef.Name} ({ex.Message}); skipping prediction."); + continue; + } + + if (definition?.Data?.Execution?.HasPost != true) + { + continue; + } + + // Compute the Post display name exactly as ActionRunner does + // when it constructs the Post IActionRunner (ActionRunner.cs:115-122). + var displayName = runner.DisplayName; + if (string.IsNullOrEmpty(displayName)) + { + displayName = "step"; + } + if (runner.Stage == ActionRunStage.Pre && + displayName.StartsWith("Pre ", StringComparison.OrdinalIgnoreCase)) + { + displayName = displayName.Substring("Pre ".Length); + } + var postDisplayName = $"Post {displayName}"; + + var entry = new JobExecutionViewEntry( + phase: JobExecutionPhase.Post, + displayName: postDisplayName, + uses: StepEntryTranslator.FormatActionReference(action.Reference)); + + predictions.Add((entry, MatchKeyFor(action.Id))); + } + + // Reverse declaration order so the rendered post section + // matches the LIFO order in which the runner will pop posts. + predictions.Reverse(); + + foreach (var (entry, key) in predictions) + { + try + { + view.Append(entry, stepIdentity: null, matchKey: key); + } + catch (Exception ex) + { + Trace.Warning("DAP predictor: failed to append Post placeholder; skipping."); + Trace.Error(ex); + } + } + } + + // Stable, opaque key derived from an action's Pipelines.ActionStep.Id. + // All IActionRunner instances for the same action (Pre/Main/Post) + // share the same Action reference (see ActionRunner.cs:131), so the + // Id is constant across phases and is the right join key. + private static string MatchKeyFor(Guid actionId) => + $"post:{actionId:N}"; + internal async Task HandleMessageAsync(string messageJson, CancellationToken cancellationToken) { Request request = null; @@ -468,6 +794,8 @@ namespace GitHub.Runner.Worker.Dap "next" => HandleNext(request), "setBreakpoints" => HandleSetBreakpoints(request), "setExceptionBreakpoints" => HandleSetExceptionBreakpoints(request), + "source" => HandleSource(request), + "loadedSources" => HandleLoadedSources(request), "completions" => HandleCompletions(request), "stepIn" => CreateResponse(request, false, "Step In is not supported. Actions jobs debug at the step level - use 'next' to advance to the next step.", body: null), "stepOut" => CreateResponse(request, false, "Step Out is not supported. Actions jobs debug at the step level - use 'continue' to resume.", body: null), @@ -855,7 +1183,7 @@ namespace GitHub.Runner.Worker.Dap internal async Task OnStepStartingAsync(IStep step, bool isFirstStep) { - bool pauseOnNextStep; + bool shouldPause; CancellationToken cancellationToken; lock (_stateLock) { @@ -867,18 +1195,14 @@ namespace GitHub.Runner.Worker.Dap } _currentStep = step; - _currentStepIndex = _completedSteps.Count; - pauseOnNextStep = _pauseOnNextStep; cancellationToken = _jobContext?.CancellationToken ?? CancellationToken.None; + shouldPause = ShouldPauseBefore(step, isFirstStep); } // Reset variable references so stale nested refs from the // previous step are not served to the client. _variableProvider?.Reset(); - // Determine if we should pause - bool shouldPause = isFirstStep || pauseOnNextStep; - if (!shouldPause) { Trace.Info("Step starting without debugger pause"); @@ -902,6 +1226,29 @@ namespace GitHub.Runner.Worker.Dap await WaitForCommandAsync(cancellationToken); } + /// + /// Decides whether the debugger should pause before . + /// Today: pause on the first step always; otherwise pause when the user + /// has elected step-mode (the 'next' command). Future breakpoint support + /// will be a single additional check here against a per-step breakpoint set. + /// Caller MUST hold _stateLock. + /// + private bool ShouldPauseBefore(IStep step, bool isFirstStep) + { + if (isFirstStep) + { + return true; + } + + if (_pauseOnNextStep) + { + return true; + } + + // TODO Phase 2c+1: if (_breakpointSet.Contains(step)) return true; + return false; + } + internal void OnJobCompleted() { Trace.Info("Job completed, sending terminated event"); @@ -973,7 +1320,7 @@ namespace GitHub.Runner.Worker.Dap SupportsTerminateRequest = false, SupportTerminateDebuggee = false, SupportsDelayedStackTraceLoading = false, - SupportsLoadedSourcesRequest = false, + SupportsLoadedSourcesRequest = true, SupportsProgressReporting = false, SupportsRunInTerminalRequest = false, SupportsCancelRequest = false, @@ -1047,72 +1394,155 @@ namespace GitHub.Runner.Worker.Dap return CreateResponse(request, true, body: body); } - private Response HandleStackTrace(Request request) + internal Response HandleStackTrace(Request request) { IStep currentStep; - int currentStepIndex; - CompletedStepInfo[] completedSteps; + JobExecutionView view; lock (_stateLock) { currentStep = _currentStep; - currentStepIndex = _currentStepIndex; - completedSteps = _completedSteps.ToArray(); + view = _executionView; } var frames = new List(); - // Add current step as the top frame - if (currentStep != null) + if (view != null) { - var resultIndicator = currentStep.ExecutionContext?.Result != null - ? $" [{currentStep.ExecutionContext.Result}]" - : " [running]"; + var source = BuildExecutionViewSource(view.JobId); - frames.Add(new StackFrame + // Frame 0: the currently-executing step (only when one is set). + if (currentStep != null) { - Id = _currentFrameId, - Name = MaskUserVisibleText($"{currentStep.DisplayName ?? "Current Step"}{resultIndicator}"), - Line = currentStepIndex + 1, - Column = 1, - PresentationHint = "normal" - }); - } - else - { - frames.Add(new StackFrame - { - Id = _currentFrameId, - Name = "(no step executing)", - Line = 0, - Column = 1, - PresentationHint = "subtle" - }); - } + var stepLine = view.TryGetLineForStep(currentStep) ?? 1; + frames.Add(new StackFrame + { + Id = _currentFrameId, + Name = MaskUserVisibleText(currentStep.DisplayName ?? "step"), + Line = stepLine, + Column = 1, + Source = source, + PresentationHint = "normal", + }); + } - // Add completed steps as additional frames (most recent first) - for (int i = completedSteps.Length - 1; i >= 0; i--) - { - var completedStep = completedSteps[i]; - var resultStr = completedStep.Result.HasValue ? $" [{completedStep.Result}]" : ""; + // Frame 1: the job (anchors the stack; line 1 = the synthesized header). frames.Add(new StackFrame { - Id = completedStep.FrameId, - Name = MaskUserVisibleText($"{completedStep.DisplayName}{resultStr}"), + Id = _jobFrameId, + Name = MaskUserVisibleText($"job: {view.JobId}"), Line = 1, Column = 1, - PresentationHint = "subtle" + Source = source, + PresentationHint = "subtle", + }); + } + else if (currentStep != null) + { + // Defensive: view not yet built but a step is executing. + // Still emit a single frame with no Source so the client doesn't choke. + frames.Add(new StackFrame + { + Id = _currentFrameId, + Name = MaskUserVisibleText(currentStep.DisplayName ?? "step"), + Line = 1, + Column = 1, + PresentationHint = "normal", }); } var body = new StackTraceResponseBody { StackFrames = frames, - TotalFrames = frames.Count + TotalFrames = frames.Count, }; return CreateResponse(request, true, body: body); } + /// + /// Builds the synthesized job execution view descriptor. + /// All frames in a session share one Source; the client retrieves its + /// content via the DAP source request keyed by . + /// + private Source BuildExecutionViewSource(string jobId) + { + return new Source + { + Name = MaskUserVisibleText("execution.yml"), + Path = MaskUserVisibleText($"{jobId}/execution.yml"), + SourceReference = _executionViewSourceReference, + PresentationHint = "normal", + }; + } + + internal Response HandleSource(Request request) + { + SourceArguments args; + try + { + args = request.Arguments?.ToObject(); + } + catch (Exception ex) + { + Trace.Warning($"Failed to parse source arguments: {ex.GetType().Name}"); + return CreateResponse(request, false, "Invalid source arguments.", body: null); + } + + if (args == null) + { + return CreateResponse(request, false, "Missing source arguments.", body: null); + } + + JobExecutionView view; + lock (_stateLock) + { + view = _executionView; + } + + if (view == null) + { + return CreateResponse(request, false, "Execution view not yet available.", body: null); + } + + if (args.SourceReference != _executionViewSourceReference) + { + return CreateResponse(request, false, $"Unknown source reference: {args.SourceReference}.", body: null); + } + + var body = new SourceResponseBody + { + Content = MaskUserVisibleText(view.Yaml), + // MimeType intentionally unset: VS Code's debug content provider + // short-circuits language detection on the response's mimeType + // (exact-match against its registered language mimetypes) and + // falls back to plaintext on unknown values. The IANA YAML type + // "application/yaml" is not in VS Code's table (it only knows + // the legacy "text/x-yaml" synthesized for the built-in YAML + // language contribution). By omitting mimeType, clients fall + // through to path-extension detection — `.yml` in Source.Path + // is the universal mechanism every DAP client honors + // consistently (VS Code, nvim-dap, JetBrains). + }; + + return CreateResponse(request, true, body: body); + } + + internal Response HandleLoadedSources(Request request) + { + JobExecutionView view; + lock (_stateLock) + { + view = _executionView; + } + + var body = new LoadedSourcesResponseBody(); + if (view != null) + { + body.Sources.Add(BuildExecutionViewSource(view.JobId)); + } + return CreateResponse(request, true, body: body); + } + private Response HandleScopes(Request request) { var args = request.Arguments?.ToObject(); @@ -1333,11 +1763,40 @@ namespace GitHub.Runner.Worker.Dap return CreateResponse(request, true, body: null); } - private Response HandleSetBreakpoints(Request request) + internal Response HandleSetBreakpoints(Request request) { - // MVP: acknowledge but don't process breakpoints - // All steps pause automatically via _pauseOnNextStep - return CreateResponse(request, true, body: new { breakpoints = Array.Empty() }); + SetBreakpointsArguments args = null; + try + { + args = request.Arguments?.ToObject(); + } + catch (Exception ex) + { + Trace.Warning($"Failed to parse setBreakpoints arguments: {ex.GetType().Name}"); + } + + JobExecutionView view; + lock (_stateLock) + { + view = _executionView; + } + + var body = new SetBreakpointsResponseBody(); + if (args?.Breakpoints != null && view != null) + { + var source = BuildExecutionViewSource(view.JobId); + foreach (var requested in args.Breakpoints) + { + body.Breakpoints.Add(new Breakpoint + { + Verified = false, + Line = requested.Line, + Source = source, + Message = "Breakpoint support is coming in a future runner release. The debugger currently pauses at every step boundary; use 'continue' to advance.", + }); + } + } + return CreateResponse(request, true, body: body); } private Response HandleSetExceptionBreakpoints(Request request) @@ -1402,8 +1861,7 @@ namespace GitHub.Runner.Worker.Dap /// /// Resolves the execution context for a given stack frame ID. - /// Frame 1 = current step; frames 1000+ = completed steps (no - /// context available - those steps have already finished). + /// Frame 1 = current step; frame 2 = job-level (subtle anchor frame). /// Falls back to the job-level context when no step is active. /// private IExecutionContext GetExecutionContextForFrame(int frameId) @@ -1413,7 +1871,7 @@ namespace GitHub.Runner.Worker.Dap return GetCurrentExecutionContext(); } - // Completed-step frames don't carry a live execution context. + // Job/anchor frame — no step-level context. return null; } @@ -1484,6 +1942,33 @@ namespace GitHub.Runner.Worker.Dap SendOutput("console", $"\nCommands will run on {target}\n"); } + /// + /// Sends a loadedSource event with the current execution view's source. + /// No-op if the view has not been built yet. + /// + private void SendLoadedSourceEvent(string reason) + { + JobExecutionView view; + lock (_stateLock) + { + view = _executionView; + } + if (view == null) + { + return; + } + + SendEvent(new Event + { + EventType = "loadedSource", + Body = new LoadedSourceEventBody + { + Reason = reason, + Source = BuildExecutionViewSource(view.JobId), + }, + }); + } + private string MaskUserVisibleText(string value) { if (string.IsNullOrEmpty(value)) diff --git a/src/Runner.Worker/Dap/DapMessages.cs b/src/Runner.Worker/Dap/DapMessages.cs index 53cd7a436..a07e96001 100644 --- a/src/Runner.Worker/Dap/DapMessages.cs +++ b/src/Runner.Worker/Dap/DapMessages.cs @@ -537,6 +537,132 @@ namespace GitHub.Runner.Worker.Dap #endregion + #region Source Request/Response + + /// + /// Arguments for 'source' request. + /// + public class SourceArguments + { + /// + /// Source descriptor (optional, redundant with sourceReference). + /// + [JsonProperty("source", NullValueHandling = NullValueHandling.Ignore)] + public Source Source { get; set; } + + /// + /// The reference to the source. Required by DAP spec. + /// + [JsonProperty("sourceReference")] + public int SourceReference { get; set; } + } + + /// + /// Response body for 'source' request. + /// + public class SourceResponseBody + { + /// + /// Content of the source as a string. + /// + [JsonProperty("content")] + public string Content { get; set; } + + /// + /// Optional content type / mime type of the source. + /// + [JsonProperty("mimeType", NullValueHandling = NullValueHandling.Ignore)] + public string MimeType { get; set; } + } + + #endregion + + #region LoadedSources Request/Response + + /// + /// Response body for 'loadedSources' request. + /// + public class LoadedSourcesResponseBody + { + [JsonProperty("sources")] + public List Sources { get; set; } = new List(); + } + + /// + /// Body for 'loadedSource' event. + /// + public class LoadedSourceEventBody + { + /// + /// "new" | "changed" | "removed" + /// + [JsonProperty("reason")] + public string Reason { get; set; } + + [JsonProperty("source")] + public Source Source { get; set; } + } + + #endregion + + #region SetBreakpoints Request/Response + + /// + /// Arguments for 'setBreakpoints' request. + /// + public class SetBreakpointsArguments + { + [JsonProperty("source", NullValueHandling = NullValueHandling.Ignore)] + public Source Source { get; set; } + + [JsonProperty("breakpoints")] + public List Breakpoints { get; set; } = new List(); + } + + /// + /// Properties of a breakpoint passed to the setBreakpoints request. + /// + public class SourceBreakpoint + { + [JsonProperty("line")] + public int Line { get; set; } + + [JsonProperty("condition", NullValueHandling = NullValueHandling.Ignore)] + public string Condition { get; set; } + + [JsonProperty("logMessage", NullValueHandling = NullValueHandling.Ignore)] + public string LogMessage { get; set; } + } + + /// + /// Response body for 'setBreakpoints' request. + /// + public class SetBreakpointsResponseBody + { + [JsonProperty("breakpoints")] + public List Breakpoints { get; set; } = new List(); + } + + /// + /// Information about a breakpoint created in setBreakpoints. + /// + public class Breakpoint + { + [JsonProperty("verified")] + public bool Verified { get; set; } + + [JsonProperty("line", NullValueHandling = NullValueHandling.Ignore)] + public int? Line { get; set; } + + [JsonProperty("source", NullValueHandling = NullValueHandling.Ignore)] + public Source Source { get; set; } + + [JsonProperty("message", NullValueHandling = NullValueHandling.Ignore)] + public string Message { get; set; } + } + + #endregion + #region Scopes Request/Response /// diff --git a/src/Runner.Worker/Dap/IDapDebugger.cs b/src/Runner.Worker/Dap/IDapDebugger.cs index 07ebcab69..f161c6aad 100644 --- a/src/Runner.Worker/Dap/IDapDebugger.cs +++ b/src/Runner.Worker/Dap/IDapDebugger.cs @@ -1,4 +1,5 @@ -using System.Threading.Tasks; +using System.Collections.Generic; +using System.Threading.Tasks; using GitHub.Runner.Common; namespace GitHub.Runner.Worker.Dap @@ -21,6 +22,23 @@ namespace GitHub.Runner.Worker.Dap Task WaitUntilReadyAsync(); Task OnStepStartingAsync(IStep step); void OnStepCompleted(IStep step); + + /// + /// Called after JobExtension.InitializeJob has returned and the initial + /// step queue + post-step stack have been populated. The debugger uses + /// these snapshots to build the synthesized job execution view served + /// via the DAP source request. + /// + Task OnJobStepsInitializedAsync(IEnumerable mainQueue, IEnumerable initialPostStack); + + /// + /// Called from ExecutionContext.RegisterPostJobStep after a post-step + /// is pushed onto the post-job stack. The debugger appends the step + /// to the running execution view so the rendered YAML reflects the + /// newly-known post-step. + /// + void OnPostStepRegistered(IStep step); + Task OnJobCompletedAsync(); Task StopAsync(); } diff --git a/src/Test/L0/Worker/DapDebuggerL0.cs b/src/Test/L0/Worker/DapDebuggerL0.cs index 92efbaa00..2a957a470 100644 --- a/src/Test/L0/Worker/DapDebuggerL0.cs +++ b/src/Test/L0/Worker/DapDebuggerL0.cs @@ -1090,5 +1090,576 @@ namespace GitHub.Runner.Common.Tests.Worker await _debugger.StopAsync(); } } + + // --------------------------------------------------------------------- + // Phase 2c: synthesized execution view as DAP source. + // --------------------------------------------------------------------- + + private static Mock NewActionRunner( + GitHub.Runner.Worker.ActionRunStage stage, + string displayName, + string actionName = "actions/checkout", + string actionRef = "v4") + { + var mock = new Mock(); + mock.SetupGet(x => x.Stage).Returns(stage); + mock.SetupGet(x => x.DisplayName).Returns(displayName); + mock.SetupGet(x => x.Action).Returns(new GitHub.DistributedTask.Pipelines.ActionStep + { + Reference = new GitHub.DistributedTask.Pipelines.RepositoryPathReference + { + Name = actionName, + Ref = actionRef, + }, + }); + return mock; + } + + private async Task DriveDebuggerToReadyAsync(int port) + { + var waitTask = _debugger.WaitUntilReadyAsync(); + var client = new TcpClient(); + await client.ConnectAsync(IPAddress.Loopback, port); + var stream = client.GetStream(); + await SendRequestAsync(stream, new Request + { + Seq = 1, + Type = "request", + Command = "configurationDone", + }); + await waitTask; + // Hold the client alive via GC root in caller scope through field. + _liveDriveClient = client; + } + + private TcpClient _liveDriveClient; + + private static Request MakeRequest(string command, object args = null) + { + return new Request + { + Seq = 1, + Type = "request", + Command = command, + Arguments = args == null ? null : Newtonsoft.Json.Linq.JObject.FromObject(args), + }; + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task HandleSource_ReturnsExecutionViewYaml() + { + using (CreateTestContext()) + { + var port = GetFreePort(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var jobContext = CreateJobContextWithTunnel(cts.Token, port, "ci-job"); + await _debugger.StartAsync(jobContext.Object); + try + { + await DriveDebuggerToReadyAsync(port); + var step = NewActionRunner(GitHub.Runner.Worker.ActionRunStage.Main, "Run").Object; + await _debugger.OnJobStepsInitializedAsync(new[] { step }, Array.Empty()); + + var response = _debugger.HandleSource(MakeRequest("source", new SourceArguments { SourceReference = 1 })); + Assert.True(response.Success); + var body = Assert.IsType(response.Body); + Assert.Equal(_debugger.ExecutionView.Yaml, body.Content); + Assert.Null(body.MimeType); + } + finally + { + await _debugger.StopAsync(); + } + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task HandleSource_UnknownReference_Fails() + { + using (CreateTestContext()) + { + var port = GetFreePort(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var jobContext = CreateJobContextWithTunnel(cts.Token, port, "ci-job"); + await _debugger.StartAsync(jobContext.Object); + try + { + await DriveDebuggerToReadyAsync(port); + var step = NewActionRunner(GitHub.Runner.Worker.ActionRunStage.Main, "Run").Object; + await _debugger.OnJobStepsInitializedAsync(new[] { step }, Array.Empty()); + + var response = _debugger.HandleSource(MakeRequest("source", new SourceArguments { SourceReference = 999 })); + Assert.False(response.Success); + Assert.Contains("Unknown source reference", response.Message); + } + finally + { + await _debugger.StopAsync(); + } + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task HandleSource_NoView_Fails() + { + using (CreateTestContext()) + { + var port = GetFreePort(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var jobContext = CreateJobContextWithTunnel(cts.Token, port, "ci-job"); + await _debugger.StartAsync(jobContext.Object); + try + { + await DriveDebuggerToReadyAsync(port); + // No OnJobStepsInitializedAsync call — view is null. + var response = _debugger.HandleSource(MakeRequest("source", new SourceArguments { SourceReference = 1 })); + Assert.False(response.Success); + Assert.Contains("Execution view not yet available", response.Message); + } + finally + { + await _debugger.StopAsync(); + } + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task HandleSource_OmitsMimeType() + { + using (CreateTestContext()) + { + var port = GetFreePort(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var jobContext = CreateJobContextWithTunnel(cts.Token, port, "ci-job"); + await _debugger.StartAsync(jobContext.Object); + try + { + await DriveDebuggerToReadyAsync(port); + var step = NewActionRunner(GitHub.Runner.Worker.ActionRunStage.Main, "Run").Object; + await _debugger.OnJobStepsInitializedAsync(new[] { step }, Array.Empty()); + + var response = _debugger.HandleSource(MakeRequest("source", new SourceArguments { SourceReference = 1 })); + Assert.True(response.Success); + var body = Assert.IsType(response.Body); + // MimeType is intentionally omitted so DAP clients fall through + // to path-extension detection (`.yml`) — the most consistently + // honored mechanism across VS Code, nvim-dap, and JetBrains. + Assert.Null(body.MimeType); + } + finally + { + await _debugger.StopAsync(); + } + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task HandleSource_MasksSecrets() + { + const string Secret = "supersecret-shhh-value"; + using (var hc = CreateTestContext()) + { + hc.SecretMasker.AddValue(Secret); + + var port = GetFreePort(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var jobContext = CreateJobContextWithTunnel(cts.Token, port, "ci-job"); + await _debugger.StartAsync(jobContext.Object); + try + { + await DriveDebuggerToReadyAsync(port); + // Embed the secret in a step display name so it ends up in the rendered YAML. + var step = NewActionRunner(GitHub.Runner.Worker.ActionRunStage.Main, $"Run {Secret} step").Object; + await _debugger.OnJobStepsInitializedAsync(new[] { step }, Array.Empty()); + + // Sanity-check the raw view actually contains the secret; + // otherwise the masking assertion below would pass vacuously. + Assert.Contains(Secret, _debugger.ExecutionView.Yaml); + + var response = _debugger.HandleSource(MakeRequest("source", new SourceArguments { SourceReference = 1 })); + Assert.True(response.Success); + var body = Assert.IsType(response.Body); + Assert.DoesNotContain(Secret, body.Content); + Assert.Contains("***", body.Content); + } + finally + { + await _debugger.StopAsync(); + } + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task HandleStackTrace_MasksSecretsInSourcePath() + { + const string Secret = "secret-job-name-xyz"; + using (var hc = CreateTestContext()) + { + hc.SecretMasker.AddValue(Secret); + + var port = GetFreePort(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + // Job name (== GitHub context "job") embeds the secret. + var jobContext = CreateJobContextWithTunnel(cts.Token, port, Secret); + await _debugger.StartAsync(jobContext.Object); + try + { + await DriveDebuggerToReadyAsync(port); + var step = NewActionRunner(GitHub.Runner.Worker.ActionRunStage.Main, "Run").Object; + await _debugger.OnJobStepsInitializedAsync(new[] { step }, Array.Empty()); + + _ = _debugger.OnStepStartingAsync(step, isFirstStep: false); + await Task.Delay(50); + + var response = _debugger.HandleStackTrace(MakeRequest("stackTrace")); + var body = Assert.IsType(response.Body); + Assert.NotEmpty(body.StackFrames); + foreach (var frame in body.StackFrames) + { + if (frame.Source != null) + { + Assert.DoesNotContain(Secret, frame.Source.Path ?? string.Empty); + Assert.DoesNotContain(Secret, frame.Source.Name ?? string.Empty); + Assert.Contains("***", frame.Source.Path ?? string.Empty); + } + Assert.DoesNotContain(Secret, frame.Name ?? string.Empty); + } + } + finally + { + await _debugger.StopAsync(); + } + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task HandleStackTrace_TwoFramesWhenStepping() + { + using (CreateTestContext()) + { + var port = GetFreePort(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var jobContext = CreateJobContextWithTunnel(cts.Token, port, "ci-job"); + await _debugger.StartAsync(jobContext.Object); + try + { + await DriveDebuggerToReadyAsync(port); + var step1 = NewActionRunner(GitHub.Runner.Worker.ActionRunStage.Main, "Step One").Object; + var step2 = NewActionRunner(GitHub.Runner.Worker.ActionRunStage.Main, "Step Two", "actions/setup-node", "v3").Object; + await _debugger.OnJobStepsInitializedAsync(new[] { step1, step2 }, Array.Empty()); + + // Fire-and-forget: first step pauses, but we just want _currentStep set. + _ = _debugger.OnStepStartingAsync(step1, isFirstStep: false); + await Task.Delay(50); + + var response = _debugger.HandleStackTrace(MakeRequest("stackTrace")); + var body = Assert.IsType(response.Body); + Assert.Equal(2, body.StackFrames.Count); + + var frame0 = body.StackFrames[0]; + Assert.Equal(1, frame0.Source.SourceReference); + Assert.Equal(_debugger.ExecutionView.TryGetLineForStep(step1), frame0.Line); + Assert.Equal("normal", frame0.PresentationHint); + + var frame1 = body.StackFrames[1]; + Assert.Equal(1, frame1.Line); + Assert.Equal("subtle", frame1.PresentationHint); + Assert.Equal(1, frame1.Source.SourceReference); + } + finally + { + await _debugger.StopAsync(); + } + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task HandleStackTrace_OneFrameWhenViewMissing() + { + using (CreateTestContext()) + { + var port = GetFreePort(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var jobContext = CreateJobContextWithTunnel(cts.Token, port, "ci-job"); + await _debugger.StartAsync(jobContext.Object); + try + { + await DriveDebuggerToReadyAsync(port); + var step = NewActionRunner(GitHub.Runner.Worker.ActionRunStage.Main, "Lonely").Object; + // No view built — but pause the step so _currentStep is set. + _ = _debugger.OnStepStartingAsync(step, isFirstStep: false); + await Task.Delay(50); + + var response = _debugger.HandleStackTrace(MakeRequest("stackTrace")); + var body = Assert.IsType(response.Body); + Assert.Single(body.StackFrames); + Assert.Null(body.StackFrames[0].Source); + } + finally + { + await _debugger.StopAsync(); + } + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task HandleStackTrace_NoFramesWhenIdle() + { + using (CreateTestContext()) + { + var port = GetFreePort(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var jobContext = CreateJobContextWithTunnel(cts.Token, port, "ci-job"); + await _debugger.StartAsync(jobContext.Object); + try + { + await DriveDebuggerToReadyAsync(port); + var response = _debugger.HandleStackTrace(MakeRequest("stackTrace")); + var body = Assert.IsType(response.Body); + Assert.Empty(body.StackFrames); + Assert.Equal(0, body.TotalFrames); + } + finally + { + await _debugger.StopAsync(); + } + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task HandleLoadedSources_ReturnsExecutionView() + { + using (CreateTestContext()) + { + var port = GetFreePort(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var jobContext = CreateJobContextWithTunnel(cts.Token, port, "ci-job"); + await _debugger.StartAsync(jobContext.Object); + try + { + await DriveDebuggerToReadyAsync(port); + var step = NewActionRunner(GitHub.Runner.Worker.ActionRunStage.Main, "Run").Object; + await _debugger.OnJobStepsInitializedAsync(new[] { step }, Array.Empty()); + + var response = _debugger.HandleLoadedSources(MakeRequest("loadedSources")); + Assert.True(response.Success); + var body = Assert.IsType(response.Body); + Assert.Single(body.Sources); + Assert.Equal("execution.yml", body.Sources[0].Name); + Assert.Equal("ci-job/execution.yml", body.Sources[0].Path); + Assert.Equal(1, body.Sources[0].SourceReference); + } + finally + { + await _debugger.StopAsync(); + } + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task HandleLoadedSources_NoView_ReturnsEmpty() + { + using (CreateTestContext()) + { + var port = GetFreePort(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var jobContext = CreateJobContextWithTunnel(cts.Token, port, "ci-job"); + await _debugger.StartAsync(jobContext.Object); + try + { + await DriveDebuggerToReadyAsync(port); + var response = _debugger.HandleLoadedSources(MakeRequest("loadedSources")); + Assert.True(response.Success); + var body = Assert.IsType(response.Body); + Assert.Empty(body.Sources); + } + finally + { + await _debugger.StopAsync(); + } + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task HandleSetBreakpoints_ReturnsUnverifiedPlaceholders() + { + using (CreateTestContext()) + { + var port = GetFreePort(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var jobContext = CreateJobContextWithTunnel(cts.Token, port, "ci-job"); + await _debugger.StartAsync(jobContext.Object); + try + { + await DriveDebuggerToReadyAsync(port); + var step = NewActionRunner(GitHub.Runner.Worker.ActionRunStage.Main, "Run").Object; + await _debugger.OnJobStepsInitializedAsync(new[] { step }, Array.Empty()); + + var args = new SetBreakpointsArguments + { + Source = new Source { SourceReference = 1 }, + Breakpoints = new System.Collections.Generic.List + { + new SourceBreakpoint { Line = 5 }, + new SourceBreakpoint { Line = 10 }, + new SourceBreakpoint { Line = 15 }, + }, + }; + + var response = _debugger.HandleSetBreakpoints(MakeRequest("setBreakpoints", args)); + Assert.True(response.Success); + var body = Assert.IsType(response.Body); + Assert.Equal(3, body.Breakpoints.Count); + Assert.All(body.Breakpoints, bp => Assert.False(bp.Verified)); + Assert.Equal(new[] { 5, 10, 15 }, body.Breakpoints.ConvertAll(b => b.Line ?? -1)); + Assert.All(body.Breakpoints, bp => Assert.False(string.IsNullOrEmpty(bp.Message))); + } + finally + { + await _debugger.StopAsync(); + } + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task OnJobStepsInitialized_EmitsLoadedSourceNewEvent() + { + using (CreateTestContext()) + { + var port = GetFreePort(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var jobContext = CreateJobContextWithTunnel(cts.Token, port, "ci-job"); + await _debugger.StartAsync(jobContext.Object); + using var client = await ConnectClientAsync(port); + try + { + var stream = client.GetStream(); + await SendRequestAsync(stream, new Request { Seq = 1, Type = "request", Command = "configurationDone" }); + await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); // configurationDone response + await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); // welcome output event + await _debugger.WaitUntilReadyAsync(); + + var step = NewActionRunner(GitHub.Runner.Worker.ActionRunStage.Main, "Run").Object; + await _debugger.OnJobStepsInitializedAsync(new[] { step }, Array.Empty()); + + var msg = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); + Assert.Contains("\"event\":\"loadedSource\"", msg); + Assert.Contains("\"reason\":\"new\"", msg); + Assert.Contains("\"sourceReference\":1", msg); + } + finally + { + await _debugger.StopAsync(); + } + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task OnPostStepRegistered_EmitsLoadedSourceChangedEvent() + { + using (CreateTestContext()) + { + var port = GetFreePort(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var jobContext = CreateJobContextWithTunnel(cts.Token, port, "ci-job"); + await _debugger.StartAsync(jobContext.Object); + using var client = await ConnectClientAsync(port); + try + { + var stream = client.GetStream(); + await SendRequestAsync(stream, new Request { Seq = 1, Type = "request", Command = "configurationDone" }); + await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); // configurationDone response + await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); // welcome output event + await _debugger.WaitUntilReadyAsync(); + + var main = NewActionRunner(GitHub.Runner.Worker.ActionRunStage.Main, "Run").Object; + await _debugger.OnJobStepsInitializedAsync(new[] { main }, Array.Empty()); + // Drain the "new" loadedSource event. + await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); + + var post = NewActionRunner(GitHub.Runner.Worker.ActionRunStage.Post, "Post Run", "actions/cache", "v3").Object; + _debugger.OnPostStepRegistered(post); + + var msg = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); + Assert.Contains("\"event\":\"loadedSource\"", msg); + Assert.Contains("\"reason\":\"changed\"", msg); + Assert.Contains("\"sourceReference\":1", msg); + } + finally + { + await _debugger.StopAsync(); + } + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task StackTrace_LineUpdatesAsStepsAdvance() + { + using (CreateTestContext()) + { + var port = GetFreePort(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var jobContext = CreateJobContextWithTunnel(cts.Token, port, "ci-job"); + await _debugger.StartAsync(jobContext.Object); + try + { + await DriveDebuggerToReadyAsync(port); + var s1 = NewActionRunner(GitHub.Runner.Worker.ActionRunStage.Main, "Step 1", "a/b", "v1").Object; + var s2 = NewActionRunner(GitHub.Runner.Worker.ActionRunStage.Main, "Step 2", "c/d", "v2").Object; + await _debugger.OnJobStepsInitializedAsync(new[] { s1, s2 }, Array.Empty()); + + _ = _debugger.OnStepStartingAsync(s1, isFirstStep: true); + await Task.Delay(50); + + var first = _debugger.HandleStackTrace(MakeRequest("stackTrace")); + var firstBody = Assert.IsType(first.Body); + int firstLine = firstBody.StackFrames[0].Line; + Assert.Equal(_debugger.ExecutionView.TryGetLineForStep(s1), firstLine); + + _ = _debugger.OnStepStartingAsync(s2, isFirstStep: false); + await Task.Delay(50); + + var second = _debugger.HandleStackTrace(MakeRequest("stackTrace")); + var secondBody = Assert.IsType(second.Body); + int secondLine = secondBody.StackFrames[0].Line; + Assert.Equal(_debugger.ExecutionView.TryGetLineForStep(s2), secondLine); + Assert.NotEqual(firstLine, secondLine); + } + finally + { + await _debugger.StopAsync(); + } + } + } } } diff --git a/src/Test/L0/Worker/JobExecutionViewLifecycleL0.cs b/src/Test/L0/Worker/JobExecutionViewLifecycleL0.cs new file mode 100644 index 000000000..2b56ce5a5 --- /dev/null +++ b/src/Test/L0/Worker/JobExecutionViewLifecycleL0.cs @@ -0,0 +1,703 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Sockets; +using System.Runtime.CompilerServices; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using GitHub.DistributedTask.Pipelines; +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 JobExecutionViewLifecycleL0 + { + private DapDebugger _debugger; + + private TestHostContext CreateTestContext([CallerMemberName] string testName = "") + { + var hc = new TestHostContext(this, testName); + _debugger = new DapDebugger(); + _debugger.Initialize(hc); + _debugger.SkipTunnelRelay = true; + _debugger.SkipWebSocketBridge = true; + return hc; + } + + private static ushort GetFreePort() + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + return (ushort)((IPEndPoint)listener.LocalEndpoint).Port; + } + + private static Mock CreateJobContextWithTunnel(CancellationToken cancellationToken, ushort port, string jobName = "ci-job") + { + var tunnel = new GitHub.DistributedTask.Pipelines.DebuggerTunnelInfo + { + TunnelId = "test-tunnel", + ClusterId = "test-cluster", + HostToken = "test-token", + Port = port + }; + var debuggerConfig = new DebuggerConfig(true, tunnel); + var jobContext = new Mock(); + jobContext.Setup(x => x.CancellationToken).Returns(cancellationToken); + jobContext.Setup(x => x.Global).Returns(new GlobalContext { Debugger = debuggerConfig }); + jobContext + .Setup(x => x.GetGitHubContext(It.IsAny())) + .Returns((string contextName) => string.Equals(contextName, "job", StringComparison.Ordinal) ? jobName : null); + return jobContext; + } + + private static async Task DriveToReadyAsync(DapDebugger debugger, int port) + { + var waitTask = debugger.WaitUntilReadyAsync(); + var client = new TcpClient(); + await client.ConnectAsync(IPAddress.Loopback, port); + var stream = client.GetStream(); + var request = new Request { Seq = 1, Type = "request", Command = "configurationDone" }; + var json = JsonConvert.SerializeObject(request); + var body = Encoding.UTF8.GetBytes(json); + var header = Encoding.ASCII.GetBytes($"Content-Length: {body.Length}\r\n\r\n"); + await stream.WriteAsync(header, 0, header.Length); + await stream.WriteAsync(body, 0, body.Length); + await stream.FlushAsync(); + await waitTask; + // Keep client alive by holding a reference via GC root in caller scope. + // We deliberately don't dispose here; tests dispose the context. + _ = client; + } + + private static Mock NewActionRunner(ActionRunStage stage, string displayName, string actionName = "actions/checkout", string actionRef = "v4", Guid actionId = default) + { + var mock = new Mock(); + mock.SetupGet(x => x.Stage).Returns(stage); + mock.SetupGet(x => x.DisplayName).Returns(displayName); + mock.SetupGet(x => x.Action).Returns(new ActionStep + { + Id = actionId, + Reference = new RepositoryPathReference { Name = actionName, Ref = actionRef }, + }); + return mock; + } + + private static Mock NewSelfActionRunner(ActionRunStage stage, string displayName, Guid actionId = default) + { + // RepositoryType = "self" — the predictor must skip these. + var mock = new Mock(); + mock.SetupGet(x => x.Stage).Returns(stage); + mock.SetupGet(x => x.DisplayName).Returns(displayName); + mock.SetupGet(x => x.Action).Returns(new ActionStep + { + Id = actionId, + Reference = new RepositoryPathReference + { + RepositoryType = GitHub.DistributedTask.Pipelines.PipelineConstants.SelfAlias, + Path = "./.github/actions/local", + }, + }); + return mock; + } + + private static Mock NewScriptActionRunner(ActionRunStage stage, string displayName, Guid actionId = default) + { + // ScriptReference — a `run:` step. Not a RepositoryPathReference, + // so the predictor's pattern match falls through. + var mock = new Mock(); + mock.SetupGet(x => x.Stage).Returns(stage); + mock.SetupGet(x => x.DisplayName).Returns(displayName); + mock.SetupGet(x => x.Action).Returns(new ActionStep + { + Id = actionId, + Reference = new ScriptReference(), + }); + return mock; + } + + // IActionManager mock that returns specific Definitions per action by + // matching on the action's reference Name. Actions whose name is not + // in the map get a Definition with HasPost = false. + private static Mock NewActionManagerWithPost(params string[] actionNamesWithPost) + { + var withPost = new HashSet(actionNamesWithPost, StringComparer.Ordinal); + var mock = new Mock(); + mock.Setup(x => x.LoadAction(It.IsAny(), It.IsAny())) + .Returns((IExecutionContext _, ActionStep step) => + { + var name = (step.Reference as RepositoryPathReference)?.Name ?? ""; + return new Definition + { + Data = new ActionDefinitionData + { + Execution = withPost.Contains(name) + ? new NodeJSActionExecutionData { Post = "post.js" } + : new NodeJSActionExecutionData(), + }, + }; + }); + return mock; + } + + private static IStep NewJobExtensionRunner(string displayName) + { + return new JobExtensionRunner( + runAsync: (_, __) => Task.CompletedTask, + condition: null, + displayName: displayName, + data: null); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task OnJobStepsInitialized_NotActive_NoOps() + { + using (CreateTestContext()) + { + var step = NewActionRunner(ActionRunStage.Main, "Run").Object; + + await _debugger.OnJobStepsInitializedAsync(new[] { step }, Array.Empty()); + + Assert.Null(_debugger.ExecutionView); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task OnPostStepRegistered_NotActive_NoOps() + { + using (CreateTestContext()) + { + var step = NewActionRunner(ActionRunStage.Post, "Post Run").Object; + _debugger.OnPostStepRegistered(step); // must not throw + Assert.Null(_debugger.ExecutionView); + await Task.CompletedTask; + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task OnJobStepsInitialized_Active_BuildsView() + { + using (CreateTestContext()) + { + var port = GetFreePort(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var jobContext = CreateJobContextWithTunnel(cts.Token, port); + await _debugger.StartAsync(jobContext.Object); + try + { + await DriveToReadyAsync(_debugger, port); + + var main1 = NewActionRunner(ActionRunStage.Main, "Run actions/checkout@v4").Object; + var main2 = NewActionRunner(ActionRunStage.Main, "Run actions/setup-node@v3", "actions/setup-node", "v3").Object; + var jobExt = NewJobExtensionRunner("Set up job"); + var post1 = NewActionRunner(ActionRunStage.Post, "Post Run actions/checkout@v4").Object; + + await _debugger.OnJobStepsInitializedAsync( + new IStep[] { main1, jobExt, main2 }, + new IStep[] { post1 }); + + var view = _debugger.ExecutionView; + Assert.NotNull(view); + Assert.Equal(3, view.EntryCount); // jobExt filtered out + Assert.Contains("Run actions/checkout@v4", view.Yaml); + Assert.Contains("Run actions/setup-node@v3", view.Yaml); + Assert.Contains("Post Run actions/checkout@v4", view.Yaml); + Assert.NotNull(view.TryGetLineForStep(main1)); + Assert.NotNull(view.TryGetLineForStep(main2)); + Assert.NotNull(view.TryGetLineForStep(post1)); + } + finally + { + await _debugger.StopAsync(); + } + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task OnJobStepsInitialized_PreservesQueueOrder() + { + using (CreateTestContext()) + { + var port = GetFreePort(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var jobContext = CreateJobContextWithTunnel(cts.Token, port); + await _debugger.StartAsync(jobContext.Object); + try + { + await DriveToReadyAsync(_debugger, port); + + var s1 = NewActionRunner(ActionRunStage.Main, "Step 1", "a/b", "v1").Object; + var s2 = NewActionRunner(ActionRunStage.Main, "Step 2", "c/d", "v2").Object; + var s3 = NewActionRunner(ActionRunStage.Main, "Step 3", "e/f", "v3").Object; + + await _debugger.OnJobStepsInitializedAsync(new[] { s1, s2, s3 }, Array.Empty()); + + var view = _debugger.ExecutionView; + Assert.Equal(3, view.EntryCount); + var l1 = view.TryGetLineForStep(s1); + var l2 = view.TryGetLineForStep(s2); + var l3 = view.TryGetLineForStep(s3); + Assert.NotNull(l1); + Assert.NotNull(l2); + Assert.NotNull(l3); + Assert.True(l1 < l2); + Assert.True(l2 < l3); + Assert.Equal(view.GetLine(0), l1); + Assert.Equal(view.GetLine(1), l2); + Assert.Equal(view.GetLine(2), l3); + } + finally + { + await _debugger.StopAsync(); + } + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task OnPostStepRegistered_AppendsToView() + { + using (CreateTestContext()) + { + var port = GetFreePort(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var jobContext = CreateJobContextWithTunnel(cts.Token, port); + await _debugger.StartAsync(jobContext.Object); + try + { + await DriveToReadyAsync(_debugger, port); + + var main1 = NewActionRunner(ActionRunStage.Main, "Run actions/checkout@v4").Object; + await _debugger.OnJobStepsInitializedAsync(new[] { main1 }, Array.Empty()); + Assert.Equal(1, _debugger.ExecutionView.EntryCount); + + var post1 = NewActionRunner(ActionRunStage.Post, "Post Run actions/cache@v3", "actions/cache", "v3").Object; + _debugger.OnPostStepRegistered(post1); + + var view = _debugger.ExecutionView; + Assert.Equal(2, view.EntryCount); + Assert.Contains("Post Run actions/cache@v3", view.Yaml); + Assert.NotNull(view.TryGetLineForStep(post1)); + } + finally + { + await _debugger.StopAsync(); + } + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task OnPostStepRegistered_BeforeViewBuilt_NoOps() + { + using (CreateTestContext()) + { + var port = GetFreePort(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var jobContext = CreateJobContextWithTunnel(cts.Token, port); + await _debugger.StartAsync(jobContext.Object); + try + { + await DriveToReadyAsync(_debugger, port); + + var post = NewActionRunner(ActionRunStage.Post, "Post Run").Object; + _debugger.OnPostStepRegistered(post); // must not throw + + Assert.Null(_debugger.ExecutionView); + } + finally + { + await _debugger.StopAsync(); + } + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task OnPostStepRegistered_DuplicateStep_DoesNotThrow() + { + using (CreateTestContext()) + { + var port = GetFreePort(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var jobContext = CreateJobContextWithTunnel(cts.Token, port); + await _debugger.StartAsync(jobContext.Object); + try + { + await DriveToReadyAsync(_debugger, port); + await _debugger.OnJobStepsInitializedAsync(Array.Empty(), Array.Empty()); + + var post = NewActionRunner(ActionRunStage.Post, "Post Run").Object; + _debugger.OnPostStepRegistered(post); + _debugger.OnPostStepRegistered(post); // duplicate, must be silently ignored + + Assert.Equal(1, _debugger.ExecutionView.EntryCount); + } + finally + { + await _debugger.StopAsync(); + } + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task OnPostStepRegistered_FilteredStep_NoOps() + { + using (CreateTestContext()) + { + var port = GetFreePort(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var jobContext = CreateJobContextWithTunnel(cts.Token, port); + await _debugger.StartAsync(jobContext.Object); + try + { + await DriveToReadyAsync(_debugger, port); + await _debugger.OnJobStepsInitializedAsync(Array.Empty(), Array.Empty()); + + var before = _debugger.ExecutionView.EntryCount; + _debugger.OnPostStepRegistered(NewJobExtensionRunner("Cleanup")); + Assert.Equal(before, _debugger.ExecutionView.EntryCount); + } + finally + { + await _debugger.StopAsync(); + } + } + } + + // ---- Predictive Post-step synthesis ---- + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task OnJobStepsInitialized_PredictsPostForActionsWithHasPost() + { + using (var hc = CreateTestContext()) + { + hc.SetSingleton(NewActionManagerWithPost("actions/has-post").Object); + + var port = GetFreePort(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var jobContext = CreateJobContextWithTunnel(cts.Token, port); + await _debugger.StartAsync(jobContext.Object); + try + { + await DriveToReadyAsync(_debugger, port); + + var withPost = NewActionRunner(ActionRunStage.Main, "Run actions/has-post@v1", "actions/has-post", "v1", actionId: Guid.NewGuid()).Object; + var noPost = NewActionRunner(ActionRunStage.Main, "Run actions/no-post@v1", "actions/no-post", "v1", actionId: Guid.NewGuid()).Object; + + await _debugger.OnJobStepsInitializedAsync(new[] { withPost, noPost }, Array.Empty()); + + var view = _debugger.ExecutionView; + Assert.NotNull(view); + // 2 main entries + 1 predicted post placeholder. + Assert.Equal(3, view.EntryCount); + Assert.Contains("post:\n", view.Yaml); + Assert.Contains("Post Run actions/has-post@v1", view.Yaml); + Assert.DoesNotContain("Post Run actions/no-post@v1", view.Yaml); + } + finally + { + await _debugger.StopAsync(); + } + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task OnJobStepsInitialized_PostPredictionsInReverseOrder() + { + using (var hc = CreateTestContext()) + { + // Both actions have post — predictions must render in + // reverse declaration order to mirror the runner's LIFO + // post-execution order. + hc.SetSingleton(NewActionManagerWithPost("actions/a", "actions/b").Object); + + var port = GetFreePort(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var jobContext = CreateJobContextWithTunnel(cts.Token, port); + await _debugger.StartAsync(jobContext.Object); + try + { + await DriveToReadyAsync(_debugger, port); + + var aMain = NewActionRunner(ActionRunStage.Main, "Run actions/a@v1", "actions/a", "v1", actionId: Guid.NewGuid()).Object; + var bMain = NewActionRunner(ActionRunStage.Main, "Run actions/b@v1", "actions/b", "v1", actionId: Guid.NewGuid()).Object; + + await _debugger.OnJobStepsInitializedAsync(new[] { aMain, bMain }, Array.Empty()); + + string yaml = _debugger.ExecutionView.Yaml; + int idxPostB = yaml.IndexOf("Post Run actions/b@v1", StringComparison.Ordinal); + int idxPostA = yaml.IndexOf("Post Run actions/a@v1", StringComparison.Ordinal); + Assert.True(idxPostB > 0 && idxPostA > 0, "both post placeholders expected"); + // Reverse declaration order: Post B appears BEFORE Post A. + Assert.True(idxPostB < idxPostA, $"expected Post B before Post A (b={idxPostB} a={idxPostA})"); + } + finally + { + await _debugger.StopAsync(); + } + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task OnJobStepsInitialized_SkipsScriptSteps() + { + using (var hc = CreateTestContext()) + { + // Even if the action manager would say HasPost, the predictor + // must skip script run-steps because their reference is not + // a RepositoryPathReference. + hc.SetSingleton(NewActionManagerWithPost(/* nothing */).Object); + + var port = GetFreePort(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var jobContext = CreateJobContextWithTunnel(cts.Token, port); + await _debugger.StartAsync(jobContext.Object); + try + { + await DriveToReadyAsync(_debugger, port); + + var script = NewScriptActionRunner(ActionRunStage.Main, "Run script", Guid.NewGuid()).Object; + await _debugger.OnJobStepsInitializedAsync(new[] { script }, Array.Empty()); + + var view = _debugger.ExecutionView; + Assert.NotNull(view); + Assert.DoesNotContain("post:\n", view.Yaml); + Assert.DoesNotContain("Post ", view.Yaml); + } + finally + { + await _debugger.StopAsync(); + } + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task OnJobStepsInitialized_SkipsSelfActions() + { + using (var hc = CreateTestContext()) + { + // Self-action: ActionRunner.cs:106 guards against creating a + // Post for self-repository references. The predictor mirrors + // that, regardless of what the manifest reports. + hc.SetSingleton(NewActionManagerWithPost("anything").Object); + + var port = GetFreePort(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var jobContext = CreateJobContextWithTunnel(cts.Token, port); + await _debugger.StartAsync(jobContext.Object); + try + { + await DriveToReadyAsync(_debugger, port); + + var selfRunner = NewSelfActionRunner(ActionRunStage.Main, "Run ./local-action", Guid.NewGuid()).Object; + await _debugger.OnJobStepsInitializedAsync(new[] { selfRunner }, Array.Empty()); + + Assert.DoesNotContain("post:\n", _debugger.ExecutionView.Yaml); + } + finally + { + await _debugger.StopAsync(); + } + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task OnPostStepRegistered_ClaimsExistingPlaceholder() + { + using (var hc = CreateTestContext()) + { + hc.SetSingleton(NewActionManagerWithPost("actions/has-post").Object); + + var port = GetFreePort(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var jobContext = CreateJobContextWithTunnel(cts.Token, port); + await _debugger.StartAsync(jobContext.Object); + try + { + await DriveToReadyAsync(_debugger, port); + + var actionId = Guid.NewGuid(); + var mainRunner = NewActionRunner(ActionRunStage.Main, "Run actions/has-post@v1", "actions/has-post", "v1", actionId: actionId).Object; + await _debugger.OnJobStepsInitializedAsync(new[] { mainRunner }, Array.Empty()); + + var view = _debugger.ExecutionView; + int before = view.EntryCount; + Assert.Equal(2, before); // main + predicted post placeholder + + // The real Post IActionRunner shares the same Action.Id + // as the Main runner (ActionRunner.cs:131). + var postRunner = NewActionRunner(ActionRunStage.Post, "Post actions/has-post@v1", "actions/has-post", "v1", actionId: actionId).Object; + _debugger.OnPostStepRegistered(postRunner); + + // No new entry: the placeholder was claimed. + Assert.Equal(before, view.EntryCount); + Assert.NotNull(view.TryGetLineForStep(postRunner)); + } + finally + { + await _debugger.StopAsync(); + } + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task OnPostStepRegistered_UnpredictedFallsBackToAppend() + { + using (var hc = CreateTestContext()) + { + // Manager returns no HasPost — no predictions made. + hc.SetSingleton(NewActionManagerWithPost(/* nothing */).Object); + + var port = GetFreePort(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var jobContext = CreateJobContextWithTunnel(cts.Token, port); + await _debugger.StartAsync(jobContext.Object); + try + { + await DriveToReadyAsync(_debugger, port); + + var mainRunner = NewActionRunner(ActionRunStage.Main, "Run actions/a@v1", "actions/a", "v1", actionId: Guid.NewGuid()).Object; + await _debugger.OnJobStepsInitializedAsync(new[] { mainRunner }, Array.Empty()); + + var view = _debugger.ExecutionView; + int before = view.EntryCount; + Assert.Equal(1, before); // just main, no predicted post + + var unpredictedPost = NewActionRunner(ActionRunStage.Post, "Post Surprise", "actions/surprise", "v1", actionId: Guid.NewGuid()).Object; + _debugger.OnPostStepRegistered(unpredictedPost); + + // Falls back to Append. + Assert.Equal(before + 1, view.EntryCount); + Assert.NotNull(view.TryGetLineForStep(unpredictedPost)); + Assert.Contains("Post Surprise", view.Yaml); + } + finally + { + await _debugger.StopAsync(); + } + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task OnPostStepRegistered_DuplicateClaim_NoDoubleEntry() + { + using (var hc = CreateTestContext()) + { + hc.SetSingleton(NewActionManagerWithPost("actions/has-post").Object); + + var port = GetFreePort(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var jobContext = CreateJobContextWithTunnel(cts.Token, port); + await _debugger.StartAsync(jobContext.Object); + try + { + await DriveToReadyAsync(_debugger, port); + + var actionId = Guid.NewGuid(); + var mainRunner = NewActionRunner(ActionRunStage.Main, "Run actions/has-post@v1", "actions/has-post", "v1", actionId: actionId).Object; + await _debugger.OnJobStepsInitializedAsync(new[] { mainRunner }, Array.Empty()); + Assert.Equal(2, _debugger.ExecutionView.EntryCount); + + // First registration claims the placeholder. + var post1 = NewActionRunner(ActionRunStage.Post, "Post actions/has-post@v1", "actions/has-post", "v1", actionId: actionId).Object; + _debugger.OnPostStepRegistered(post1); + Assert.Equal(2, _debugger.ExecutionView.EntryCount); + + // Second registration with the same Action.Id but a + // different IStep: TryClaim returns null (already + // claimed). Falls through to Append. But the entry + // it builds matches no existing step, so a new entry + // would be added — UNLESS we constructed the second + // post as a duplicate IStep registration of the same + // step. Here we intentionally pass the same `post1` + // step a second time — Append will reject the + // already-registered step, the handler swallows it. + _debugger.OnPostStepRegistered(post1); + + Assert.Equal(2, _debugger.ExecutionView.EntryCount); + } + finally + { + await _debugger.StopAsync(); + } + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task OnStepCompleted_SkippedMainStep_MarksPostPlaceholder() + { + using (var hc = CreateTestContext()) + { + hc.SetSingleton(NewActionManagerWithPost("actions/has-post").Object); + + var port = GetFreePort(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var jobContext = CreateJobContextWithTunnel(cts.Token, port); + await _debugger.StartAsync(jobContext.Object); + try + { + await DriveToReadyAsync(_debugger, port); + + var actionId = Guid.NewGuid(); + var mainMock = NewActionRunner(ActionRunStage.Main, "Run actions/has-post@v1", "actions/has-post", "v1", actionId: actionId); + var execCtx = new Mock(); + execCtx.SetupGet(x => x.Result).Returns(TaskResult.Skipped); + mainMock.SetupGet(x => x.ExecutionContext).Returns(execCtx.Object); + + await _debugger.OnJobStepsInitializedAsync(new[] { mainMock.Object }, Array.Empty()); + + var view = _debugger.ExecutionView; + Assert.Equal(2, view.EntryCount); // main + predicted post placeholder + Assert.DoesNotContain("(skipped", view.Yaml); + + _debugger.OnStepCompleted(mainMock.Object); + + Assert.Equal(2, _debugger.ExecutionView.EntryCount); + Assert.Contains("(skipped — main step did not execute)", _debugger.ExecutionView.Yaml); + // Inline annotation must not have introduced a new line. + Assert.Equal(view.Yaml.Split('\n').Length, _debugger.ExecutionView.Yaml.Split('\n').Length); + } + finally + { + await _debugger.StopAsync(); + } + } + } + } +}