mirror of
https://github.com/actions/runner.git
synced 2026-07-03 11:06:08 +08:00
Wire execution-view source and stack handlers in DapDebugger
Adds DAP source, stackTrace, and loadedSources handlers backed by a
synthesized JobExecutionView. The view is served via sourceReference,
with a synthetic .yml path so clients pick YAML syntax highlighting.
User-visible text (path, name, content) is run through the secret
masker.
Adds three lifecycle methods to IDapDebugger:
- OnJobStepsInitializedAsync seeds the view and synthesizes Post
placeholders predictively from action Pre/Post metadata, so VS
Code's sourceReference cache hits a stable view.
- OnPostStepRegistered claims a predicted placeholder when the real
Post IActionRunner is registered, or appends a new entry.
- OnStepCompleted marks predicted Post placeholders as skipped when
their Main step is skipped.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Stores information about a completed step for stack trace display.
|
||||
/// </summary>
|
||||
internal sealed class CompletedStepInfo
|
||||
{
|
||||
public string DisplayName { get; set; }
|
||||
public TaskResult? Result { get; set; }
|
||||
public int FrameId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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<CompletedStepInfo> _completedSteps = new List<CompletedStepInfo>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
internal JobExecutionView ExecutionView
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_stateLock)
|
||||
{
|
||||
return _executionView;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task OnJobStepsInitializedAsync(IEnumerable<IStep> mainQueue, IEnumerable<IStep> 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<IStep>() : new List<IStep>(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<T>.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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Walks <paramref name="mainSteps"/> (the queue of Pre+Main
|
||||
/// IActionRunners produced by JobRunner) and synthesizes a Post
|
||||
/// placeholder entry on <paramref name="view"/> for every action
|
||||
/// whose manifest declares <c>HasPost = true</c>.
|
||||
///
|
||||
/// Conditions mirror <c>ActionRunner.RunAsync</c> exactly:
|
||||
/// the runner is in Pre or Main stage, the action is a
|
||||
/// <see cref="Pipelines.RepositoryPathReference"/> that is NOT the
|
||||
/// self-repository alias, the action is not a script, and the
|
||||
/// resolved <see cref="Definition"/> 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).
|
||||
/// </summary>
|
||||
private void PredictPostPlaceholders(IExecutionContext jobContext, IReadOnlyList<IStep> mainSteps, JobExecutionView view)
|
||||
{
|
||||
if (jobContext == null || mainSteps == null || mainSteps.Count == 0 || view == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
IActionManager actionManager;
|
||||
try
|
||||
{
|
||||
actionManager = HostContext.GetService<IActionManager>();
|
||||
}
|
||||
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<Guid>();
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decides whether the debugger should pause before <paramref name="step"/>.
|
||||
/// 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 <c>_stateLock</c>.
|
||||
/// </summary>
|
||||
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<StackFrame>();
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the synthesized job execution view <see cref="Source"/> descriptor.
|
||||
/// All frames in a session share one Source; the client retrieves its
|
||||
/// content via the DAP <c>source</c> request keyed by <see cref="_executionViewSourceReference"/>.
|
||||
/// </summary>
|
||||
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<SourceArguments>();
|
||||
}
|
||||
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<ScopesArguments>();
|
||||
@@ -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<object>() });
|
||||
SetBreakpointsArguments args = null;
|
||||
try
|
||||
{
|
||||
args = request.Arguments?.ToObject<SetBreakpointsArguments>();
|
||||
}
|
||||
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
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a loadedSource event with the current execution view's source.
|
||||
/// No-op if the view has not been built yet.
|
||||
/// </summary>
|
||||
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))
|
||||
|
||||
@@ -537,6 +537,132 @@ namespace GitHub.Runner.Worker.Dap
|
||||
|
||||
#endregion
|
||||
|
||||
#region Source Request/Response
|
||||
|
||||
/// <summary>
|
||||
/// Arguments for 'source' request.
|
||||
/// </summary>
|
||||
public class SourceArguments
|
||||
{
|
||||
/// <summary>
|
||||
/// Source descriptor (optional, redundant with sourceReference).
|
||||
/// </summary>
|
||||
[JsonProperty("source", NullValueHandling = NullValueHandling.Ignore)]
|
||||
public Source Source { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The reference to the source. Required by DAP spec.
|
||||
/// </summary>
|
||||
[JsonProperty("sourceReference")]
|
||||
public int SourceReference { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response body for 'source' request.
|
||||
/// </summary>
|
||||
public class SourceResponseBody
|
||||
{
|
||||
/// <summary>
|
||||
/// Content of the source as a string.
|
||||
/// </summary>
|
||||
[JsonProperty("content")]
|
||||
public string Content { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional content type / mime type of the source.
|
||||
/// </summary>
|
||||
[JsonProperty("mimeType", NullValueHandling = NullValueHandling.Ignore)]
|
||||
public string MimeType { get; set; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region LoadedSources Request/Response
|
||||
|
||||
/// <summary>
|
||||
/// Response body for 'loadedSources' request.
|
||||
/// </summary>
|
||||
public class LoadedSourcesResponseBody
|
||||
{
|
||||
[JsonProperty("sources")]
|
||||
public List<Source> Sources { get; set; } = new List<Source>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Body for 'loadedSource' event.
|
||||
/// </summary>
|
||||
public class LoadedSourceEventBody
|
||||
{
|
||||
/// <summary>
|
||||
/// "new" | "changed" | "removed"
|
||||
/// </summary>
|
||||
[JsonProperty("reason")]
|
||||
public string Reason { get; set; }
|
||||
|
||||
[JsonProperty("source")]
|
||||
public Source Source { get; set; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region SetBreakpoints Request/Response
|
||||
|
||||
/// <summary>
|
||||
/// Arguments for 'setBreakpoints' request.
|
||||
/// </summary>
|
||||
public class SetBreakpointsArguments
|
||||
{
|
||||
[JsonProperty("source", NullValueHandling = NullValueHandling.Ignore)]
|
||||
public Source Source { get; set; }
|
||||
|
||||
[JsonProperty("breakpoints")]
|
||||
public List<SourceBreakpoint> Breakpoints { get; set; } = new List<SourceBreakpoint>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Properties of a breakpoint passed to the setBreakpoints request.
|
||||
/// </summary>
|
||||
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; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response body for 'setBreakpoints' request.
|
||||
/// </summary>
|
||||
public class SetBreakpointsResponseBody
|
||||
{
|
||||
[JsonProperty("breakpoints")]
|
||||
public List<Breakpoint> Breakpoints { get; set; } = new List<Breakpoint>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Information about a breakpoint created in setBreakpoints.
|
||||
/// </summary>
|
||||
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
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -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);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
Task OnJobStepsInitializedAsync(IEnumerable<IStep> mainQueue, IEnumerable<IStep> initialPostStack);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
void OnPostStepRegistered(IStep step);
|
||||
|
||||
Task OnJobCompletedAsync();
|
||||
Task StopAsync();
|
||||
}
|
||||
|
||||
@@ -1090,5 +1090,576 @@ namespace GitHub.Runner.Common.Tests.Worker
|
||||
await _debugger.StopAsync();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Phase 2c: synthesized execution view as DAP source.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
private static Mock<GitHub.Runner.Worker.IActionRunner> NewActionRunner(
|
||||
GitHub.Runner.Worker.ActionRunStage stage,
|
||||
string displayName,
|
||||
string actionName = "actions/checkout",
|
||||
string actionRef = "v4")
|
||||
{
|
||||
var mock = new Mock<GitHub.Runner.Worker.IActionRunner>();
|
||||
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<IStep>());
|
||||
|
||||
var response = _debugger.HandleSource(MakeRequest("source", new SourceArguments { SourceReference = 1 }));
|
||||
Assert.True(response.Success);
|
||||
var body = Assert.IsType<SourceResponseBody>(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<IStep>());
|
||||
|
||||
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<IStep>());
|
||||
|
||||
var response = _debugger.HandleSource(MakeRequest("source", new SourceArguments { SourceReference = 1 }));
|
||||
Assert.True(response.Success);
|
||||
var body = Assert.IsType<SourceResponseBody>(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<IStep>());
|
||||
|
||||
// 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<SourceResponseBody>(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<IStep>());
|
||||
|
||||
_ = _debugger.OnStepStartingAsync(step, isFirstStep: false);
|
||||
await Task.Delay(50);
|
||||
|
||||
var response = _debugger.HandleStackTrace(MakeRequest("stackTrace"));
|
||||
var body = Assert.IsType<StackTraceResponseBody>(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<IStep>());
|
||||
|
||||
// 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<StackTraceResponseBody>(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<StackTraceResponseBody>(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<StackTraceResponseBody>(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<IStep>());
|
||||
|
||||
var response = _debugger.HandleLoadedSources(MakeRequest("loadedSources"));
|
||||
Assert.True(response.Success);
|
||||
var body = Assert.IsType<LoadedSourcesResponseBody>(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<LoadedSourcesResponseBody>(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<IStep>());
|
||||
|
||||
var args = new SetBreakpointsArguments
|
||||
{
|
||||
Source = new Source { SourceReference = 1 },
|
||||
Breakpoints = new System.Collections.Generic.List<SourceBreakpoint>
|
||||
{
|
||||
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<SetBreakpointsResponseBody>(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<IStep>());
|
||||
|
||||
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<IStep>());
|
||||
// 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<IStep>());
|
||||
|
||||
_ = _debugger.OnStepStartingAsync(s1, isFirstStep: true);
|
||||
await Task.Delay(50);
|
||||
|
||||
var first = _debugger.HandleStackTrace(MakeRequest("stackTrace"));
|
||||
var firstBody = Assert.IsType<StackTraceResponseBody>(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<StackTraceResponseBody>(second.Body);
|
||||
int secondLine = secondBody.StackFrames[0].Line;
|
||||
Assert.Equal(_debugger.ExecutionView.TryGetLineForStep(s2), secondLine);
|
||||
Assert.NotEqual(firstLine, secondLine);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await _debugger.StopAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
703
src/Test/L0/Worker/JobExecutionViewLifecycleL0.cs
Normal file
703
src/Test/L0/Worker/JobExecutionViewLifecycleL0.cs
Normal file
@@ -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<IExecutionContext> 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<IExecutionContext>();
|
||||
jobContext.Setup(x => x.CancellationToken).Returns(cancellationToken);
|
||||
jobContext.Setup(x => x.Global).Returns(new GlobalContext { Debugger = debuggerConfig });
|
||||
jobContext
|
||||
.Setup(x => x.GetGitHubContext(It.IsAny<string>()))
|
||||
.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<IActionRunner> NewActionRunner(ActionRunStage stage, string displayName, string actionName = "actions/checkout", string actionRef = "v4", Guid actionId = default)
|
||||
{
|
||||
var mock = new Mock<IActionRunner>();
|
||||
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<IActionRunner> NewSelfActionRunner(ActionRunStage stage, string displayName, Guid actionId = default)
|
||||
{
|
||||
// RepositoryType = "self" — the predictor must skip these.
|
||||
var mock = new Mock<IActionRunner>();
|
||||
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<IActionRunner> 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<IActionRunner>();
|
||||
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<IActionManager> NewActionManagerWithPost(params string[] actionNamesWithPost)
|
||||
{
|
||||
var withPost = new HashSet<string>(actionNamesWithPost, StringComparer.Ordinal);
|
||||
var mock = new Mock<IActionManager>();
|
||||
mock.Setup(x => x.LoadAction(It.IsAny<IExecutionContext>(), It.IsAny<ActionStep>()))
|
||||
.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<IStep>());
|
||||
|
||||
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<IStep>());
|
||||
|
||||
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<IStep>());
|
||||
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<IStep>(), Array.Empty<IStep>());
|
||||
|
||||
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<IStep>(), Array.Empty<IStep>());
|
||||
|
||||
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<IActionManager>(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<IStep>());
|
||||
|
||||
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<IActionManager>(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<IStep>());
|
||||
|
||||
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<IActionManager>(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<IStep>());
|
||||
|
||||
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<IActionManager>(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<IStep>());
|
||||
|
||||
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<IActionManager>(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<IStep>());
|
||||
|
||||
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<IActionManager>(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<IStep>());
|
||||
|
||||
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<IActionManager>(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<IStep>());
|
||||
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<IActionManager>(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<IExecutionContext>();
|
||||
execCtx.SetupGet(x => x.Result).Returns(TaskResult.Skipped);
|
||||
mainMock.SetupGet(x => x.ExecutionContext).Returns(execCtx.Object);
|
||||
|
||||
await _debugger.OnJobStepsInitializedAsync(new[] { mainMock.Object }, Array.Empty<IStep>());
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user