Compare commits

..

10 Commits

Author SHA1 Message Date
Francesco Renzi
4dbc4349d6 Add JobExecutionView state container
The DAP debugger needs to map a runtime IStep back to a source line
when answering `stackTrace` requests. The renderer (#PR1b) produces
the YAML and per-entry start lines from an immutable list, but the
debugger's view grows over the job's lifetime: post steps register
lazily, and the integration layer needs O(1) IStep -> line lookup
at every pause.

This commit adds JobExecutionView, a stateful append-only wrapper
around the renderer. It maintains:
  - the current entry list,
  - the most recent rendered YAML,
  - a Dictionary<IStep, int> for fast line lookup.

Each Append can register an entry in one of three modes:
  - with a stepIdentity: registers the IStep -> line mapping
    immediately;
  - with a matchKey: registers an unclaimed placeholder that a
    later TryClaim binds to a real IStep (used when an entry is
    predicted before the runner materializes its IStep, e.g. a
    Post-step placeholder synthesized at job-init from an action's
    metadata);
  - with neither: a static informational entry that needs no line
    lookup.

This is part 3 of 5. The DAP-integration PR that consumes this
container is the final follow-up.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-19 02:49:32 -07:00
Francesco Renzi
c23ac2969d Add JobExecutionViewRenderer for DAP execution view
The DAP debugger serves a synthesized YAML document as the job's
`source`. That document is a 1:1 representation of how the runner
sees the job — not the workflow file — so pre and post action steps
appear as their own 'lines' that the user can pause on (and
eventually breakpoint, set in a follow-up PR).

This commit adds the core rendering algorithm: given a list of
phase-tagged entries (`JobExecutionViewEntry`), produce the
phase-keyed YAML plus a parallel array of 1-based line numbers
pointing at each entry's `- step:` key. The line numbers are what
later powers the DAP `stackTrace` handler.

Why hand-emit the skeleton instead of serializing a DTO?
Per-entry line offsets must be tracked at emission time. Using a
generic YAML serializer would force a second pass to scan the
output for `- step:` lines, which is fragile and breaks the moment
indentation conventions shift. Scalar values still go through the
library (via YamlScalarFormatter from #PR1a), so we don't carry
quoting rules.

Example output for a typical job (build, build, post step):

    # Job: build
    # Runner execution plan — read-only.

    setup:
      - step: Setup job

    main:
      - step: Run actions/checkout@v6
        uses: actions/checkout@v6
        if: success()
      - step: Cache Primes
        id: cache-primes
        uses: actions/cache@v5
        if: success()
        with:
          path: prime-numbers
          key: ${{ runner.os }}-primes

    post:
      - step: Post Cache Primes
        action: actions/cache@v5

    cleanup:
      - step: Complete job

This is part 2 of 5 splitting the previously-monolithic foundation
for review tractability. The wiring that turns runner state into
these entries lives in the next PRs.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-19 02:49:32 -07:00
Francesco Renzi
1ec6749d4b Address Copilot review feedback
- Remove the redundant second `TrimEnd('\n')` from the return path.
  The earlier trim already removes any trailing newline before the
  `\n...` doc-end check; the marker-removal substring does not
  re-introduce one, so the second trim was dead code.
- Surface full exception (`ex.ToString()`) in the test round-trip
  helper so YAML parse failures show stack + inner exception, not
  just the top-level message.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-19 02:49:32 -07:00
Francesco Renzi
699901f072 Add YamlScalarFormatter for quote-safe YAML scalars
The upcoming DAP execution-view renderer serves a synthesized YAML
document as the job's debugger source. The skeleton is hand-emitted
so we can track per-step line offsets, but scalar values (step names,
action refs, etc.) need quote-safe formatting that respects YAML's
reserved chars, leading/trailing whitespace, and embedded `: `/`#`
sequences. Doing this by hand is bug-prone and easy to get wrong on
edge cases (empty strings, expressions, multiline content).

This commit adds a thin wrapper around YamlDotNet's `Emitter` that
emits a single scalar, strips the surrounding document markers, and
forces LF line breaks (`StringWriter` otherwise picks up Windows's
CRLF via `Environment.NewLine` and corrupts the document-end
stripping).

No caller yet — the renderer that uses it lands in a follow-up PR.
This is part 1 of 5 splitting the previously-monolithic foundation
for review tractability.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-19 02:49:32 -07:00
Francesco Renzi
ae2896c551 Send welcome message in debugger console on connect (#4419)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-18 16:14:37 +00:00
Francesco Renzi
ebf33710e8 Execute debugger REPL commands inside job container (#4420)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-18 16:07:25 +00:00
github-actions[bot]
a1ccd22030 Update Docker to v29.5.0 and Buildx to v0.34.0 (#4425)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-05-18 09:57:42 -04:00
github-actions[bot]
b549247bee Update dotnet sdk to latest version @8.0.421 (#4428)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-05-18 13:31:42 +00:00
Daniel Valdivia
d36839b001 Add support for Ubuntu 26.04 (liblttng-ust1t64, libicu77-80) (#4394)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 10:51:46 -04:00
Francesco Renzi
0cdaa36d07 Move dap setup to setup job step (#4403) 2026-05-06 18:11:23 +01:00
33 changed files with 861 additions and 3265 deletions

View File

@@ -4,7 +4,7 @@
"features": {
"ghcr.io/devcontainers/features/docker-in-docker:2": {},
"ghcr.io/devcontainers/features/dotnet": {
"version": "8.0.420"
"version": "8.0.421"
},
"ghcr.io/devcontainers/features/node:1": {
"version": "20"

View File

@@ -25,11 +25,11 @@ The `installdependencies.sh` script should install all required dependencies on
Debian based OS (Debian, Ubuntu, Linux Mint)
- liblttng-ust1 or liblttng-ust0
- liblttng-ust1t64, liblttng-ust1 or liblttng-ust0
- libkrb5-3
- zlib1g
- libssl3t64, libssl3, libssl1.1, libssl1.0.2 or libssl1.0.0
- libicu76, libicu75, ..., libicu66, libicu65, libicu63, libicu60, libicu57, libicu55, or libicu52
- libicu80, libicu79, ..., libicu66, libicu65, libicu63, libicu60, libicu57, libicu55, or libicu52
Fedora based OS (Fedora, Red Hat Enterprise Linux, CentOS, Oracle Linux 7)

View File

@@ -5,8 +5,8 @@ ARG TARGETOS
ARG TARGETARCH
ARG RUNNER_VERSION
ARG RUNNER_CONTAINER_HOOKS_VERSION=0.7.0
ARG DOCKER_VERSION=29.4.0
ARG BUILDX_VERSION=0.33.0
ARG DOCKER_VERSION=29.5.0
ARG BUILDX_VERSION=0.34.0
RUN apt update -y && apt install curl unzip -y

View File

@@ -94,7 +94,7 @@ then
fi
}
apt_get_with_fallbacks liblttng-ust1 liblttng-ust0
apt_get_with_fallbacks liblttng-ust1t64 liblttng-ust1 liblttng-ust0
if [ $? -ne 0 ]
then
echo "'$apt_get' failed with exit code '$?'"
@@ -110,7 +110,7 @@ then
exit 1
fi
apt_get_with_fallbacks libicu76 libicu75 libicu74 libicu73 libicu72 libicu71 libicu70 libicu69 libicu68 libicu67 libicu66 libicu65 libicu63 libicu60 libicu57 libicu55 libicu52
apt_get_with_fallbacks libicu80 libicu79 libicu78 libicu77 libicu76 libicu75 libicu74 libicu73 libicu72 libicu71 libicu70 libicu69 libicu68 libicu67 libicu66 libicu65 libicu63 libicu60 libicu57 libicu55 libicu52
if [ $? -ne 0 ]
then
echo "'$apt_get' failed with exit code '$?'"

View File

@@ -179,6 +179,7 @@ namespace GitHub.Runner.Common
public static readonly string EmitCompositeMarkers = "actions_runner_emit_composite_markers";
public static readonly string BatchActionResolution = "actions_batch_action_resolution";
public static readonly string UseBearerTokenForCodeload = "actions_use_bearer_token_for_codeload";
public static readonly string OverrideDebuggerWelcomeMessage = "actions_runner_override_debugger_welcome_message";
}
// Node version migration related constants

View File

@@ -16,10 +16,19 @@ 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
@@ -42,12 +51,8 @@ namespace GitHub.Runner.Worker.Dap
// Frame ID for the current step (always 1)
private const int _currentFrameId = 1;
// 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;
// Frame IDs for completed steps start at 1000
private const int _completedFrameIdBase = 1000;
private TcpListener _listener;
private TcpClient _client;
@@ -58,6 +63,7 @@ namespace GitHub.Runner.Worker.Dap
private volatile DapSessionState _state = DapSessionState.NotStarted;
private CancellationTokenRegistration? _cancellationRegistration;
private bool _isFirstStep = true;
private bool _welcomeMessageSent;
// Dev Tunnel relay host for remote debugging
private TunnelRelayTunnelHost _tunnelRelayHost;
@@ -87,6 +93,11 @@ 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;
@@ -97,8 +108,6 @@ 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 ||
@@ -251,8 +260,7 @@ namespace GitHub.Runner.Worker.Dap
}
catch (Exception ex)
{
Trace.Warning("DAP job-completed pause error.");
Trace.Error(ex);
Trace.Warning($"DAP job-completed pause error: {ex.Message}");
}
}
@@ -262,8 +270,7 @@ namespace GitHub.Runner.Worker.Dap
}
catch (Exception ex)
{
Trace.Warning("DAP OnJobCompleted error.");
Trace.Error(ex);
Trace.Warning($"DAP OnJobCompleted error: {ex.Message}");
}
}
}
@@ -380,8 +387,7 @@ namespace GitHub.Runner.Worker.Dap
}
catch (Exception ex)
{
Trace.Warning("DAP OnStepStarting error.");
Trace.Error(ex);
Trace.Warning($"DAP OnStepStarting error: {ex.Message}");
}
}
@@ -394,8 +400,10 @@ namespace GitHub.Runner.Worker.Dap
try
{
var result = step.ExecutionContext?.Result;
Trace.Info("Step completed");
JobExecutionView view;
// Add to completed steps list for stack trace
lock (_stateLock)
{
if (_state != DapSessionState.Ready &&
@@ -405,353 +413,20 @@ namespace GitHub.Runner.Worker.Dap
return;
}
// Clear current-step ref if it matches; otherwise leave alone
// (defensive — OnStepStartingAsync may have already advanced it).
if (ReferenceEquals(_currentStep, step))
_completedSteps.Add(new CompletedStepInfo
{
_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");
}
DisplayName = step.DisplayName,
Result = result,
FrameId = _nextCompletedFrameId++
});
}
}
catch (Exception ex)
{
Trace.Warning("DAP OnStepCompleted error.");
Trace.Error(ex);
Trace.Warning($"DAP OnStepCompleted error: {ex.Message}");
}
}
/// <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;
@@ -793,8 +468,6 @@ 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),
@@ -818,6 +491,11 @@ namespace GitHub.Runner.Worker.Dap
});
Trace.Info("Sent initialized event");
}
if (request.Command == "configurationDone")
{
SendWelcomeMessage();
}
}
catch (Exception ex)
{
@@ -836,6 +514,7 @@ namespace GitHub.Runner.Worker.Dap
internal void HandleClientConnected()
{
_isClientConnected = true;
_welcomeMessageSent = false;
Trace.Info("Client connected to debug session");
// If we're paused, re-send the stopped event so the new client
@@ -1146,9 +825,37 @@ namespace GitHub.Runner.Worker.Dap
});
}
internal void SendWelcomeMessage()
{
if (_welcomeMessageSent)
{
return;
}
_welcomeMessageSent = true;
var debuggerConfig = _jobContext?.Global?.Debugger;
if (debuggerConfig?.OverrideWelcomeMessage == true)
{
if (!string.IsNullOrEmpty(debuggerConfig.WelcomeMessage))
{
SendOutput("console", debuggerConfig.WelcomeMessage);
Trace.Info("Sent custom welcome message");
}
else
{
Trace.Info("Welcome message suppressed by override");
}
}
else
{
SendOutput("console", DapReplParser.GetGeneralHelp());
Trace.Info("Sent default welcome message");
}
}
internal async Task OnStepStartingAsync(IStep step, bool isFirstStep)
{
bool shouldPause;
bool pauseOnNextStep;
CancellationToken cancellationToken;
lock (_stateLock)
{
@@ -1160,14 +867,18 @@ 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");
@@ -1184,33 +895,13 @@ namespace GitHub.Runner.Worker.Dap
// Send stopped event to debugger (only if client is connected)
SendStoppedEvent(reason, description);
// Emit a banner so the user knows where REPL commands will execute
SendExecutionContextBanner();
// Wait for debugger command
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");
@@ -1282,7 +973,7 @@ namespace GitHub.Runner.Worker.Dap
SupportsTerminateRequest = false,
SupportTerminateDebuggee = false,
SupportsDelayedStackTraceLoading = false,
SupportsLoadedSourcesRequest = true,
SupportsLoadedSourcesRequest = false,
SupportsProgressReporting = false,
SupportsRunInTerminalRequest = false,
SupportsCancelRequest = false,
@@ -1356,155 +1047,72 @@ namespace GitHub.Runner.Worker.Dap
return CreateResponse(request, true, body: body);
}
internal Response HandleStackTrace(Request request)
private Response HandleStackTrace(Request request)
{
IStep currentStep;
JobExecutionView view;
int currentStepIndex;
CompletedStepInfo[] completedSteps;
lock (_stateLock)
{
currentStep = _currentStep;
view = _executionView;
currentStepIndex = _currentStepIndex;
completedSteps = _completedSteps.ToArray();
}
var frames = new List<StackFrame>();
if (view != null)
// Add current step as the top frame
if (currentStep != null)
{
var source = BuildExecutionViewSource(view.JobId);
var resultIndicator = currentStep.ExecutionContext?.Result != null
? $" [{currentStep.ExecutionContext.Result}]"
: " [running]";
// Frame 0: the currently-executing step (only when one is set).
if (currentStep != null)
{
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",
});
}
// Frame 1: the job (anchors the stack; line 1 = the synthesized header).
frames.Add(new StackFrame
{
Id = _jobFrameId,
Name = MaskUserVisibleText($"job: {view.JobId}"),
Line = 1,
Column = 1,
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"),
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"
});
}
// 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}]" : "";
frames.Add(new StackFrame
{
Id = completedStep.FrameId,
Name = MaskUserVisibleText($"{completedStep.DisplayName}{resultStr}"),
Line = 1,
Column = 1,
PresentationHint = "normal",
PresentationHint = "subtle"
});
}
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>();
@@ -1625,7 +1233,12 @@ namespace GitHub.Runner.Worker.Dap
case RunCommand run:
var context = GetExecutionContextForFrame(frameId);
return await _replExecutor.ExecuteRunCommandAsync(run, context, cancellationToken);
bool isActionStep;
lock (_stateLock)
{
isActionStep = _currentStep is IActionRunner;
}
return await _replExecutor.ExecuteRunCommandAsync(run, context, isActionStep, cancellationToken);
default:
return new EvaluateResponseBody
@@ -1720,40 +1333,11 @@ namespace GitHub.Runner.Worker.Dap
return CreateResponse(request, true, body: null);
}
internal Response HandleSetBreakpoints(Request request)
private Response HandleSetBreakpoints(Request request)
{
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);
// MVP: acknowledge but don't process breakpoints
// All steps pause automatically via _pauseOnNextStep
return CreateResponse(request, true, body: new { breakpoints = Array.Empty<object>() });
}
private Response HandleSetExceptionBreakpoints(Request request)
@@ -1818,7 +1402,8 @@ namespace GitHub.Runner.Worker.Dap
/// <summary>
/// Resolves the execution context for a given stack frame ID.
/// Frame 1 = current step; frame 2 = job-level (subtle anchor frame).
/// Frame 1 = current step; frames 1000+ = completed steps (no
/// context available - those steps have already finished).
/// Falls back to the job-level context when no step is active.
/// </summary>
private IExecutionContext GetExecutionContextForFrame(int frameId)
@@ -1828,7 +1413,7 @@ namespace GitHub.Runner.Worker.Dap
return GetCurrentExecutionContext();
}
// Job/anchor frame — no step-level context.
// Completed-step frames don't carry a live execution context.
return null;
}
@@ -1866,30 +1451,37 @@ namespace GitHub.Runner.Worker.Dap
}
/// <summary>
/// Sends a loadedSource event with the current execution view's source.
/// No-op if the view has not been built yet.
/// Emits a console output banner telling the user whether REPL
/// commands will execute on the host or inside the job container.
/// </summary>
private void SendLoadedSourceEvent(string reason)
private void SendExecutionContextBanner()
{
JobExecutionView view;
lock (_stateLock)
{
view = _executionView;
}
if (view == null)
if (!_isClientConnected)
{
return;
}
SendEvent(new Event
bool isActionStep = _currentStep is IActionRunner;
var container = _jobContext?.Global?.Container;
string target;
if (isActionStep && container != null &&
(!string.IsNullOrEmpty(container.ContainerId) ||
FeatureManager.IsContainerHooksEnabled(_jobContext?.Global?.Variables)))
{
EventType = "loadedSource",
Body = new LoadedSourceEventBody
{
Reason = reason,
Source = BuildExecutionViewSource(view.JobId),
},
});
var image = container.ContainerImage ?? "container";
var shortId = !string.IsNullOrEmpty(container.ContainerId) && container.ContainerId.Length >= 12
? container.ContainerId.Substring(0, 12)
: container.ContainerId ?? "";
var idSuffix = !string.IsNullOrEmpty(shortId) ? $" ({shortId})" : "";
target = $"job container: {image}{idSuffix}";
}
else
{
target = "runner host";
}
SendOutput("console", $"\nCommands will run on {target}\n");
}
private string MaskUserVisibleText(string value)

View File

@@ -537,132 +537,6 @@ 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>

View File

@@ -9,6 +9,7 @@ using GitHub.DistributedTask.Pipelines.ContextData;
using GitHub.Runner.Common;
using GitHub.Runner.Common.Util;
using GitHub.Runner.Sdk;
using GitHub.Runner.Worker.Container;
using GitHub.Runner.Worker.Handlers;
namespace GitHub.Runner.Worker.Dap
@@ -43,6 +44,7 @@ namespace GitHub.Runner.Worker.Dap
public async Task<EvaluateResponseBody> ExecuteRunCommandAsync(
RunCommand command,
IExecutionContext context,
bool isActionStep,
CancellationToken cancellationToken)
{
if (context == null)
@@ -52,7 +54,7 @@ namespace GitHub.Runner.Worker.Dap
try
{
return await ExecuteScriptAsync(command, context, cancellationToken);
return await ExecuteScriptAsync(command, context, isActionStep, cancellationToken);
}
catch (Exception ex)
{
@@ -65,9 +67,17 @@ namespace GitHub.Runner.Worker.Dap
private async Task<EvaluateResponseBody> ExecuteScriptAsync(
RunCommand command,
IExecutionContext context,
bool isActionStep,
CancellationToken cancellationToken)
{
// 1. Resolve shell — same logic as ScriptHandler
// 1. Resolve step host — container or host, same as ActionRunner.
// Only action steps (user-defined run:/uses:) execute inside the
// container. Infrastructure steps (Set up job, Initialize
// containers, Complete job, etc.) always run on the host.
var stepHost = CreateStepHost(context, isActionStep);
var isContainerStepHost = stepHost is IContainerStepHost;
// 2. Resolve shell — same logic as ScriptHandler
string shellCommand;
string argFormat;
@@ -87,9 +97,9 @@ namespace GitHub.Runner.Worker.Dap
argFormat = ScriptHandlerHelpers.GetScriptArgumentsFormat(shellCommand);
}
_trace.Info("Resolved REPL shell");
_trace.Info($"Resolved REPL shell (container={isContainerStepHost})");
// 2. Expand ${{ }} expressions in the script body, just like
// 3. Expand ${{ }} expressions in the script body, just like
// ActionRunner evaluates step inputs before ScriptHandler sees them
var contents = ExpandExpressions(command.Script, context);
contents = ScriptHandlerHelpers.FixUpScriptContents(shellCommand, contents);
@@ -111,25 +121,47 @@ namespace GitHub.Runner.Worker.Dap
try
{
// 3. Format arguments with script path
var resolvedPath = scriptFilePath.Replace("\"", "\\\"");
// 4. Resolve script path — translate for container if needed
var resolvedPath = stepHost.ResolvePathForStepHost(context, scriptFilePath).Replace("\"", "\\\"");
if (string.IsNullOrEmpty(argFormat) || !argFormat.Contains("{0}"))
{
return ErrorResult($"Invalid shell option '{shellCommand}'. Shell must be a valid built-in (bash, sh, cmd, powershell, pwsh) or a format string containing '{{0}}'");
}
var arguments = string.Format(argFormat, resolvedPath);
// 4. Resolve shell command path
// 5. Resolve shell command path — for containers, use the shell
// name directly (it will be resolved inside the container);
// for host execution, resolve the full path on the host.
string prependPath = string.Join(
Path.PathSeparator.ToString(),
Enumerable.Reverse(context.Global.PrependPath));
var commandPath = WhichUtil.Which(shellCommand, false, _trace, prependPath)
?? shellCommand;
var fileName = isContainerStepHost
? shellCommand
: WhichUtil.Which(shellCommand, false, _trace, prependPath) ?? shellCommand;
// 5. Build environment — merge from execution context like a real step
// 6. Build environment — merge from execution context like a real step
var environment = BuildEnvironment(context, command.Env);
// 6. Resolve working directory
// 7. Handle PrependPath — mirrors Handler.AddPrependPathToEnvironment
if (context.Global.PrependPath.Count > 0)
{
if (stepHost is IContainerStepHost containerHost)
{
containerHost.PrependPath = prependPath;
}
else
{
string taskEnvPATH;
environment.TryGetValue(Constants.PathVariable, out taskEnvPATH);
string originalPath = context.Global.Variables?.Get(Constants.PathVariable) ?? // Prefer a job variable.
taskEnvPATH ?? // Then a task-environment variable.
System.Environment.GetEnvironmentVariable(Constants.PathVariable) ?? // Then an environment variable.
string.Empty;
environment[Constants.PathVariable] = PathUtil.PrependPath(prependPath, originalPath);
}
}
// 8. Resolve working directory — translate for container
var workingDirectory = command.WorkingDirectory;
if (string.IsNullOrEmpty(workingDirectory))
{
@@ -141,48 +173,60 @@ namespace GitHub.Runner.Worker.Dap
: null;
workingDirectory = workspace ?? _hostContext.GetDirectory(WellKnownDirectory.Work);
}
workingDirectory = stepHost.ResolvePathForStepHost(context, workingDirectory);
_trace.Info("Executing REPL command");
// Stream execution info to debugger
SendOutput("console", $"$ {shellCommand} {command.Script.Substring(0, Math.Min(command.Script.Length, 80))}{(command.Script.Length > 80 ? "..." : "")}\n");
// 7. Execute via IProcessInvoker (same as DefaultStepHost)
int exitCode;
using (var processInvoker = _hostContext.CreateService<IProcessInvoker>())
// NOTE: When container hooks are enabled, ContainerStepHost routes
// execution through IContainerHookManager which does not raise
// OutputDataReceived/ErrorDataReceived events. Output will not be
// streamed to the debug console in that mode.
if (isContainerStepHost && FeatureManager.IsContainerHooksEnabled(context.Global?.Variables))
{
processInvoker.OutputDataReceived += (sender, args) =>
{
if (!string.IsNullOrEmpty(args.Data))
{
var masked = _hostContext.SecretMasker.MaskSecrets(args.Data);
SendOutput("stdout", masked + "\n");
}
};
processInvoker.ErrorDataReceived += (sender, args) =>
{
if (!string.IsNullOrEmpty(args.Data))
{
var masked = _hostContext.SecretMasker.MaskSecrets(args.Data);
SendOutput("stderr", masked + "\n");
}
};
exitCode = await processInvoker.ExecuteAsync(
workingDirectory: workingDirectory,
fileName: commandPath,
arguments: arguments,
environment: environment,
requireExitCodeZero: false,
outputEncoding: null,
killProcessOnCancel: true,
cancellationToken: cancellationToken);
const string hookWarning = "Container hooks are enabled. REPL output will not be streamed to the debug console for this command.";
_trace.Warning(hookWarning);
SendOutput("stderr", hookWarning + "\n");
}
// 9. Execute via IStepHost — handles docker exec for containers,
// direct process execution for host, and container hooks
stepHost.OutputDataReceived += (sender, args) =>
{
if (!string.IsNullOrEmpty(args.Data))
{
var masked = _hostContext.SecretMasker.MaskSecrets(args.Data);
SendOutput("stdout", masked + "\n");
}
};
stepHost.ErrorDataReceived += (sender, args) =>
{
if (!string.IsNullOrEmpty(args.Data))
{
var masked = _hostContext.SecretMasker.MaskSecrets(args.Data);
SendOutput("stderr", masked + "\n");
}
};
int exitCode = await stepHost.ExecuteAsync(
context: context,
workingDirectory: workingDirectory,
fileName: fileName,
arguments: arguments,
environment: environment,
requireExitCodeZero: false,
outputEncoding: null,
killProcessOnCancel: true,
inheritConsoleHandler: false,
standardInInput: null,
cancellationToken: cancellationToken);
_trace.Info($"REPL command exited with code {exitCode}");
// 8. Return only the exit code summary (output was already streamed)
// 10. Return only the exit code summary (output was already streamed)
return new EvaluateResponseBody
{
Result = exitCode == 0 ? $"(exit code: {exitCode})" : $"Process completed with exit code {exitCode}.",
@@ -198,6 +242,43 @@ namespace GitHub.Runner.Worker.Dap
}
}
/// <summary>
/// Creates the appropriate <see cref="IStepHost"/> for the current
/// execution context, mirroring how <see cref="ActionRunner"/> decides
/// between host and container execution.
///
/// Only action steps (user-defined run:/uses: steps) run inside the
/// job container. Infrastructure steps like "Set up job", "Initialize
/// containers", "Stop containers", and "Complete job" always execute
/// on the host regardless of whether a container is configured.
/// </summary>
internal IStepHost CreateStepHost(IExecutionContext context, bool isActionStep)
{
if (!isActionStep)
{
_trace.Info("Creating DefaultStepHost for REPL execution (infrastructure step)");
return _hostContext.CreateService<IDefaultStepHost>();
}
var container = context?.Global?.Container;
if (container != null)
{
// Container hooks don't always set ContainerId, but the container
// step host handles that internally
var hooksEnabled = FeatureManager.IsContainerHooksEnabled(context.Global?.Variables);
if (hooksEnabled || !string.IsNullOrEmpty(container.ContainerId))
{
_trace.Info("Creating ContainerStepHost for REPL execution");
var containerStepHost = _hostContext.CreateService<IContainerStepHost>();
containerStepHost.Container = container;
return containerStepHost;
}
}
_trace.Info("Creating DefaultStepHost for REPL execution");
return _hostContext.CreateService<IDefaultStepHost>();
}
/// <summary>
/// Expands <c>${{ }}</c> expressions in the input string using the
/// runner's template evaluator — the same evaluation path that processes

View File

@@ -1,4 +1,4 @@
using GitHub.DistributedTask.Pipelines;
using GitHub.DistributedTask.Pipelines;
namespace GitHub.Runner.Worker.Dap
{
@@ -8,10 +8,12 @@ namespace GitHub.Runner.Worker.Dap
/// </summary>
public sealed class DebuggerConfig
{
public DebuggerConfig(bool enabled, DebuggerTunnelInfo tunnel)
public DebuggerConfig(bool enabled, DebuggerTunnelInfo tunnel, bool overrideWelcomeMessage = false, string welcomeMessage = null)
{
Enabled = enabled;
Tunnel = tunnel;
OverrideWelcomeMessage = overrideWelcomeMessage;
WelcomeMessage = welcomeMessage;
}
/// <summary>Whether the debugger is enabled for this job.</summary>
@@ -23,6 +25,19 @@ namespace GitHub.Runner.Worker.Dap
/// </summary>
public DebuggerTunnelInfo Tunnel { get; }
/// <summary>
/// When true, the runner overrides the default welcome message with
/// <see cref="WelcomeMessage"/>. A null or empty <see cref="WelcomeMessage"/>
/// suppresses the message entirely. When false, the default help text is shown.
/// </summary>
public bool OverrideWelcomeMessage { get; }
/// <summary>
/// Optional welcome message content for the debugger console. Only used when
/// <see cref="OverrideWelcomeMessage"/> is true.
/// </summary>
public string WelcomeMessage { get; }
/// <summary>Whether the tunnel configuration is complete and valid.</summary>
public bool HasValidTunnel => Tunnel != null
&& !string.IsNullOrEmpty(Tunnel.TunnelId)

View File

@@ -1,5 +1,4 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using System.Threading.Tasks;
using GitHub.Runner.Common;
namespace GitHub.Runner.Worker.Dap
@@ -22,23 +21,6 @@ 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();
}

View File

@@ -10,9 +10,21 @@ namespace GitHub.Runner.Worker.Dap
/// and provides O(1) lookup from <see cref="IStep"/> identity to the current line
/// in the rendered YAML where that step's <c>- step:</c> key appears.
///
/// Append-only growth model: post-steps are discovered lazily during execution
/// and appended. Setup/pre/main entry line numbers are stable across appends —
/// only the synthetic Cleanup boundary (which is not tracked here) shifts.
/// Each <see cref="Append"/> can register the entry in one of three modes:
/// - With a non-null <c>stepIdentity</c>: registers the IStep→line mapping
/// immediately. Used for entries whose real <see cref="IStep"/> is already
/// known at append time.
/// - With a non-null <c>matchKey</c>: registers an unclaimed placeholder
/// that a later <see cref="TryClaim"/> binds to a real <see cref="IStep"/>.
/// Used for entries whose <see cref="IStep"/> is materialized later. A
/// placeholder that is never claimed simply stays in the view and is never
/// paused on — the IStep→line mapping is only populated on claim.
/// - With neither: a static entry that needs no line lookup.
///
/// <see cref="Append"/> and <see cref="AppendRange"/> never remove or reorder
/// existing entries. <see cref="TryClaim"/> does not re-render. The IStep→line
/// mapping is rebuilt on every render, so lookups stay accurate even if a later
/// Append happens to shift previously-emitted entries.
/// </summary>
internal sealed class JobExecutionView
{
@@ -113,20 +125,25 @@ namespace GitHub.Runner.Worker.Dap
}
/// <summary>
/// Append a new entry. If <paramref name="stepIdentity"/> is non-null,
/// registers the IStep -> line mapping for later lookup. If
/// <paramref name="matchKey"/> is non-null, the entry is registered
/// as an unclaimed placeholder that a future
/// <see cref="TryClaim(string, IStep)"/> call can bind to a real
/// IStep (used by the predictive Post-step path). Re-renders the
/// YAML and updates the start-line table.
/// Append a new entry. Exactly one of <paramref name="stepIdentity"/>
/// or <paramref name="matchKey"/> may be non-null (or both may be
/// null for a static entry that needs no line lookup):
/// - <paramref name="stepIdentity"/> non-null: registers the
/// IStep→line mapping immediately. Use when the real
/// <see cref="IStep"/> is known at append time.
/// - <paramref name="matchKey"/> non-null: registers an unclaimed
/// placeholder that a later <see cref="TryClaim"/> binds to a
/// real <see cref="IStep"/>.
/// Re-renders the YAML and updates the start-line table.
/// </summary>
/// <returns>1-based line number of the newly-appended entry's <c>- step:</c> key.</returns>
public int Append(JobExecutionViewEntry entry, IStep stepIdentity = null, string matchKey = null)
{
if (entry == null)
ArgUtil.NotNull(entry, nameof(entry));
if (stepIdentity != null && matchKey != null)
{
throw new ArgumentNullException(nameof(entry));
throw new ArgumentException(
"Append cannot register both a step identity and a placeholder match key on the same entry; pass at most one.");
}
lock (_lock)
@@ -193,46 +210,6 @@ namespace GitHub.Runner.Worker.Dap
}
}
/// <summary>
/// Mark a previously-appended unclaimed placeholder as skipped. Used
/// when the predicting Main step never runs (skipped by <c>if:</c>),
/// so its predicted Post-step placeholder should not appear as a
/// step that will execute. Re-renders the view (inline comment only
/// — subsequent entry line numbers stay stable).
/// </summary>
/// <returns>
/// true if a matching unclaimed placeholder was marked; false when
/// no placeholder exists for <paramref name="matchKey"/>, or the
/// placeholder has already been claimed (claim wins).
/// </returns>
public bool TryMarkSkipped(string matchKey)
{
ArgUtil.NotNull(matchKey, nameof(matchKey));
lock (_lock)
{
if (!_unclaimedByKey.TryGetValue(matchKey, out int index))
{
return false;
}
// Defensive: only mark if it's still an unclaimed placeholder.
if (_stepIdentities[index] != null)
{
return false;
}
if (_entries[index].IsSkipped)
{
// Idempotent — already marked.
return true;
}
_entries[index].IsSkipped = true;
_unclaimedByKey.Remove(matchKey);
Render();
return true;
}
}
/// <summary>
/// Bulk-append for the initial population. Equivalent to calling
/// <see cref="Append"/> once per pair, but renders only once at the end.

View File

@@ -1,11 +1,8 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Text;
using GitHub.Runner.Sdk;
using YamlDotNet.Core;
using YamlDotNet.Core.Events;
namespace GitHub.Runner.Worker.Dap
{
@@ -88,15 +85,6 @@ namespace GitHub.Runner.Worker.Dap
public string WithYaml { get; }
public string Shell { get; }
public string WorkingDirectory { get; }
/// <summary>
/// Set when the corresponding step was skipped (e.g. predicted Post
/// placeholder for a Main step that never executed because its
/// <c>if:</c> evaluated false). Rendered as an inline YAML comment
/// on the entry's <c>- step:</c> line so subsequent entry line
/// numbers stay stable.
/// </summary>
public bool IsSkipped { get; internal set; }
}
/// <summary>
@@ -153,7 +141,7 @@ namespace GitHub.Runner.Worker.Dap
int newlinesEmitted = 0;
// Header (3 lines).
sb.Append("# Job: ").Append(FormatScalar(jobId)).Append('\n');
sb.Append("# Job: ").Append(YamlScalarFormatter.Format(jobId)).Append('\n');
sb.Append("# Runner execution plan — read-only.\n");
sb.Append('\n');
newlinesEmitted += 3;
@@ -212,12 +200,7 @@ namespace GitHub.Runner.Worker.Dap
// 1-based line of the `- step:` key for this entry.
startLines[i] = newlinesEmitted + 1;
sb.Append(" - step: ").Append(FormatScalar(entry.DisplayName));
if (entry.IsSkipped)
{
// Inline comment — keeps following entry line numbers stable.
sb.Append(" # (skipped — main step did not execute)");
}
sb.Append(" - step: ").Append(YamlScalarFormatter.Format(entry.DisplayName));
sb.Append('\n');
newlinesEmitted++;
@@ -227,7 +210,7 @@ namespace GitHub.Runner.Worker.Dap
case JobExecutionPhase.Post:
if (!string.IsNullOrEmpty(entry.Uses))
{
sb.Append(" action: ").Append(FormatScalar(entry.Uses)).Append('\n');
sb.Append(" action: ").Append(YamlScalarFormatter.Format(entry.Uses)).Append('\n');
newlinesEmitted++;
}
// No source: annotation for pre/post.
@@ -236,19 +219,19 @@ namespace GitHub.Runner.Worker.Dap
case JobExecutionPhase.Main:
if (!string.IsNullOrEmpty(entry.Id))
{
sb.Append(" id: ").Append(FormatScalar(entry.Id)).Append('\n');
sb.Append(" id: ").Append(YamlScalarFormatter.Format(entry.Id)).Append('\n');
newlinesEmitted++;
}
if (!string.IsNullOrEmpty(entry.Uses))
{
sb.Append(" uses: ").Append(FormatScalar(entry.Uses)).Append('\n');
sb.Append(" uses: ").Append(YamlScalarFormatter.Format(entry.Uses)).Append('\n');
newlinesEmitted++;
}
if (!string.IsNullOrEmpty(entry.Run))
{
if (entry.Run.IndexOf('\n') < 0)
{
sb.Append(" run: ").Append(FormatScalar(entry.Run)).Append('\n');
sb.Append(" run: ").Append(YamlScalarFormatter.Format(entry.Run)).Append('\n');
newlinesEmitted++;
}
else
@@ -260,7 +243,7 @@ namespace GitHub.Runner.Worker.Dap
}
if (!string.IsNullOrEmpty(entry.If))
{
sb.Append(" if: ").Append(FormatScalar(entry.If)).Append('\n');
sb.Append(" if: ").Append(YamlScalarFormatter.Format(entry.If)).Append('\n');
newlinesEmitted++;
}
if (!string.IsNullOrEmpty(entry.ContinueOnError))
@@ -289,12 +272,12 @@ namespace GitHub.Runner.Worker.Dap
}
if (!string.IsNullOrEmpty(entry.Shell))
{
sb.Append(" shell: ").Append(FormatScalar(entry.Shell)).Append('\n');
sb.Append(" shell: ").Append(YamlScalarFormatter.Format(entry.Shell)).Append('\n');
newlinesEmitted++;
}
if (!string.IsNullOrEmpty(entry.WorkingDirectory))
{
sb.Append(" working-directory: ").Append(FormatScalar(entry.WorkingDirectory)).Append('\n');
sb.Append(" working-directory: ").Append(YamlScalarFormatter.Format(entry.WorkingDirectory)).Append('\n');
newlinesEmitted++;
}
if (entry.SourcePath != null)
@@ -349,43 +332,5 @@ namespace GitHub.Runner.Worker.Dap
}
return n;
}
/// <summary>
/// Formats a single string as a YAML 1.x flow scalar, delegating
/// quoting/escaping decisions to YamlDotNet. This avoids maintaining
/// our own escape table for every YAML-significant character: we
/// just emit the value through the YAML library and use whichever
/// scalar style (plain, single-quoted, double-quoted) it picks.
/// A new <see cref="Emitter"/> is created per call, so the helper
/// is safe to invoke concurrently.
/// </summary>
internal static string FormatScalar(string value)
{
if (value == null)
{
throw new ArgumentNullException(nameof(value));
}
using var sw = new StringWriter(CultureInfo.InvariantCulture);
var emitter = new Emitter(sw);
emitter.Emit(new StreamStart());
emitter.Emit(new DocumentStart(null, null, true));
emitter.Emit(new Scalar(null, null, value, ScalarStyle.Any, true, true));
emitter.Emit(new DocumentEnd(true));
emitter.Emit(new StreamEnd());
string raw = sw.ToString();
if (raw.StartsWith("--- ", StringComparison.Ordinal))
{
raw = raw.Substring(4);
}
raw = raw.TrimEnd('\n');
const string DocEndMarker = "\n...";
if (raw.EndsWith(DocEndMarker, StringComparison.Ordinal))
{
raw = raw.Substring(0, raw.Length - DocEndMarker.Length);
}
return raw.TrimEnd('\n');
}
}
}

View File

@@ -1,238 +0,0 @@
using System;
using System.Collections.Generic;
using GitHub.DistributedTask.ObjectTemplating.Tokens;
using GitHub.DistributedTask.Pipelines;
using GitHub.Runner.Sdk;
namespace GitHub.Runner.Worker.Dap
{
/// <summary>
/// Translates runner <see cref="IStep"/> instances into pure-data
/// <see cref="JobExecutionViewEntry"/> records used by the DAP debugger
/// execution view. Filters out runner-internal steps (e.g.
/// <see cref="JobExtensionRunner"/>) so the rendered view only shows
/// user-visible workflow steps.
/// </summary>
internal static class StepEntryTranslator
{
// Run-step internals carried on ActionStep.Inputs that are NOT
// user-authored `with:` entries.
private static readonly HashSet<string> RunStepInternalKeys = new(StringComparer.Ordinal)
{
"script",
"shell",
"working-directory",
};
/// <summary>
/// Translate an IStep into a JobExecutionViewEntry.
/// </summary>
/// <param name="step">The IStep to translate. Must not be null.</param>
/// <returns>
/// A JobExecutionViewEntry, or null if the step is not user-visible
/// (JobExtensionRunner and any other non-IActionRunner IStep impls).
/// </returns>
public static JobExecutionViewEntry TryTranslate(IStep step)
{
ArgUtil.NotNull(step, nameof(step));
if (step is JobExtensionRunner)
{
return null;
}
if (step is not IActionRunner actionRunner)
{
return null;
}
var phase = actionRunner.Stage switch
{
ActionRunStage.Pre => JobExecutionPhase.Pre,
ActionRunStage.Post => JobExecutionPhase.Post,
_ => JobExecutionPhase.Main,
};
string displayName = actionRunner.DisplayName;
if (string.IsNullOrWhiteSpace(displayName))
{
displayName = "run";
}
string uses = null;
string run = null;
string id = null;
string ifCond = null;
string continueOnError = null;
string timeoutMinutes = null;
string envYaml = null;
string withYaml = null;
string shell = null;
string workingDirectory = null;
var action = actionRunner.Action;
var reference = action?.Reference;
bool isScript = reference?.Type == ActionSourceType.Script;
if (reference != null && !isScript)
{
uses = FormatActionReference(reference);
}
// Only the user-visible Main entry surfaces authored params.
// Pre/Post stay minimal (step + action) — they reference the
// same Action as the Main entry, and duplicating params adds
// noise without information.
if (phase == JobExecutionPhase.Main && action != null)
{
id = FilterAuthoredId(action.ContextName);
if (!string.IsNullOrEmpty(action.Condition))
{
ifCond = action.Condition;
}
if (action.ContinueOnError != null)
{
continueOnError = TemplateTokenYamlAdapter.Serialize(action.ContinueOnError, indentSpaces: 0);
}
if (action.TimeoutInMinutes != null)
{
timeoutMinutes = TemplateTokenYamlAdapter.Serialize(action.TimeoutInMinutes, indentSpaces: 0);
}
if (action.Environment is MappingToken envMap && envMap.Count > 0)
{
envYaml = TemplateTokenYamlAdapter.Serialize(envMap, indentSpaces: 6);
}
else if (action.Environment != null && !(action.Environment is MappingToken))
{
// Unusual but possible: env: ${{ ... }} expression form.
envYaml = TemplateTokenYamlAdapter.Serialize(action.Environment, indentSpaces: 6);
}
if (isScript)
{
var inputs = action.Inputs as MappingToken;
if (inputs != null)
{
if (TryGetMapValue(inputs, "script", out var scriptTok) && scriptTok != null)
{
run = scriptTok.ToString();
}
if (TryGetMapValue(inputs, "shell", out var shellTok) && shellTok != null)
{
string shellText = shellTok.ToString();
if (!string.IsNullOrEmpty(shellText))
{
shell = shellText;
}
}
if (TryGetMapValue(inputs, "working-directory", out var wdTok) && wdTok != null)
{
string wdText = wdTok.ToString();
if (!string.IsNullOrEmpty(wdText))
{
workingDirectory = wdText;
}
}
}
}
else
{
// Action step: surface `with:` entries, filtering any
// run-step internal keys defensively.
if (action.Inputs is MappingToken withMap && withMap.Count > 0)
{
var filtered = FilterMapping(withMap, RunStepInternalKeys);
if (filtered != null && filtered.Count > 0)
{
withYaml = TemplateTokenYamlAdapter.Serialize(filtered, indentSpaces: 6);
}
}
}
}
// Source annotation (SourcePath/SourceLine) requires a public
// seam onto TemplateToken position info — not wired yet.
return new JobExecutionViewEntry(
phase: phase,
displayName: displayName,
uses: uses,
run: run,
sourcePath: null,
sourceLine: 0,
id: id,
@if: ifCond,
continueOnError: continueOnError,
timeoutMinutes: timeoutMinutes,
envYaml: envYaml,
withYaml: withYaml,
shell: shell,
workingDirectory: workingDirectory);
}
/// <summary>
/// Auto-generated step IDs are noise in the view: filter them out.
/// The runner's convention (see ExecutionContext) is that auto-
/// generated context names start with <c>__</c>. Only user-authored
/// IDs survive the filter.
/// </summary>
internal static string FilterAuthoredId(string contextName)
{
if (string.IsNullOrWhiteSpace(contextName))
{
return null;
}
if (contextName.StartsWith("__", StringComparison.Ordinal))
{
return null;
}
return contextName;
}
private static bool TryGetMapValue(MappingToken map, string key, out TemplateToken value)
{
foreach (var pair in map)
{
if (pair.Key is StringToken s && string.Equals(s.Value, key, StringComparison.Ordinal))
{
value = pair.Value;
return true;
}
}
value = null;
return false;
}
private static MappingToken FilterMapping(MappingToken source, HashSet<string> excludeKeys)
{
var copy = new MappingToken(source.FileId, source.Line, source.Column);
foreach (var pair in source)
{
if (pair.Key is StringToken sk && excludeKeys.Contains(sk.Value))
{
continue;
}
copy.Add(pair);
}
return copy;
}
internal static string FormatActionReference(ActionStepDefinitionReference reference)
{
switch (reference)
{
case RepositoryPathReference repo:
var path = string.IsNullOrEmpty(repo.Path) ? string.Empty : $"/{repo.Path}";
return string.IsNullOrEmpty(repo.Ref)
? $"{repo.Name}{path}"
: $"{repo.Name}{path}@{repo.Ref}";
case ContainerRegistryReference container:
return container.Image;
default:
return reference.ToString();
}
}
}
}

View File

@@ -1,148 +0,0 @@
using System;
using System.Globalization;
using System.IO;
using GitHub.DistributedTask.ObjectTemplating;
using GitHub.DistributedTask.ObjectTemplating.Tokens;
using GitHub.Runner.Sdk;
using YamlDotNet.Core;
using YamlDotNet.Core.Events;
namespace GitHub.Runner.Worker.Dap
{
/// <summary>
/// Adapts a YamlDotNet <see cref="IEmitter"/> as a DT
/// <see cref="IObjectWriter"/> so a <see cref="TemplateToken"/> DOM
/// can be serialized back to YAML preserving its pre-evaluation form
/// (basic <c>${{ }}</c> expressions are written through verbatim).
///
/// Used by the DAP execution view to surface user-authored step
/// parameters (<c>env:</c>, <c>with:</c>, <c>run:</c>, ...) without
/// any expression substitution.
/// </summary>
internal sealed class TemplateTokenYamlAdapter : IObjectWriter
{
private readonly IEmitter _emitter;
public TemplateTokenYamlAdapter(IEmitter emitter)
{
ArgUtil.NotNull(emitter, nameof(emitter));
_emitter = emitter;
}
public void WriteStart()
{
_emitter.Emit(new StreamStart());
_emitter.Emit(new DocumentStart(null, null, true));
}
public void WriteEnd()
{
_emitter.Emit(new DocumentEnd(true));
_emitter.Emit(new StreamEnd());
}
public void WriteNull() =>
_emitter.Emit(new Scalar(null, null, "null", ScalarStyle.Plain, true, false));
public void WriteBoolean(bool value) =>
_emitter.Emit(new Scalar(null, null, value ? "true" : "false", ScalarStyle.Plain, true, false));
public void WriteNumber(double value) =>
_emitter.Emit(new Scalar(null, null, value.ToString("R", CultureInfo.InvariantCulture), ScalarStyle.Plain, true, false));
public void WriteString(string value)
{
if (value == null)
{
WriteNull();
return;
}
// Multi-line strings render as block literal so embedded
// newlines survive the YAML round trip.
var style = value.IndexOf('\n') >= 0 ? ScalarStyle.Literal : ScalarStyle.Any;
_emitter.Emit(new Scalar(null, null, value, style, true, true));
}
public void WriteSequenceStart() =>
_emitter.Emit(new SequenceStart(null, null, true, SequenceStyle.Any));
public void WriteSequenceEnd() =>
_emitter.Emit(new SequenceEnd());
public void WriteMappingStart() =>
_emitter.Emit(new MappingStart(null, null, true, MappingStyle.Any));
public void WriteMappingEnd() =>
_emitter.Emit(new MappingEnd());
/// <summary>
/// Serialize a TemplateToken to a YAML fragment ready to embed
/// under a parent key. Each non-empty line is prefixed by
/// <paramref name="indentSpaces"/> spaces. Trailing newlines and
/// the YAML stream start/document markers are stripped, so the
/// caller controls line breaks.
/// </summary>
/// <remarks>
/// Empty mappings render as <c>{}</c> and empty sequences as
/// <c>[]</c> via YamlDotNet's flow style fallback for empty
/// collections.
/// </remarks>
internal static string Serialize(TemplateToken token, int indentSpaces)
{
if (indentSpaces < 0)
{
throw new ArgumentOutOfRangeException(nameof(indentSpaces));
}
using var sw = new StringWriter(CultureInfo.InvariantCulture);
var emitter = new Emitter(sw);
var adapter = new TemplateTokenYamlAdapter(emitter);
TemplateWriter.Write(adapter, token);
string raw = sw.ToString();
// Strip YAML document markers ("--- " prefix and "\n..." suffix).
if (raw.StartsWith("--- ", StringComparison.Ordinal))
{
raw = raw.Substring(4);
}
const string DocEndMarker = "\n...";
if (raw.EndsWith(DocEndMarker + "\n", StringComparison.Ordinal))
{
raw = raw.Substring(0, raw.Length - DocEndMarker.Length - 1);
}
else if (raw.EndsWith(DocEndMarker, StringComparison.Ordinal))
{
raw = raw.Substring(0, raw.Length - DocEndMarker.Length);
}
raw = raw.TrimEnd('\n');
if (indentSpaces == 0)
{
return raw;
}
// Re-indent every non-empty line. Empty lines remain empty
// so YAML block-literal blank lines stay valid.
var pad = new string(' ', indentSpaces);
var sb = new System.Text.StringBuilder(raw.Length + indentSpaces * 4);
int i = 0;
while (i < raw.Length)
{
int end = raw.IndexOf('\n', i);
int lineEnd = end < 0 ? raw.Length : end;
if (lineEnd > i)
{
sb.Append(pad);
sb.Append(raw, i, lineEnd - i);
}
if (end < 0)
{
break;
}
sb.Append('\n');
i = end + 1;
}
return sb.ToString();
}
}
}

View File

@@ -0,0 +1,63 @@
using System;
using System.Globalization;
using System.IO;
using GitHub.Runner.Sdk;
using YamlDotNet.Core;
using YamlDotNet.Core.Events;
namespace GitHub.Runner.Worker.Dap
{
/// <summary>
/// Formats a single string as a quote-safe YAML scalar by routing it
/// through YamlDotNet's <see cref="Emitter"/>. The returned text is
/// safe to splice into a hand-emitted YAML document fragment.
///
/// Caller responsibility: this only handles the scalar value; it does
/// not emit a key, indent, or trailing newline.
/// </summary>
internal static class YamlScalarFormatter
{
/// <summary>
/// Return <paramref name="value"/> formatted as a YAML scalar:
/// plain, single-quoted, or double-quoted as the emitter chooses,
/// with no surrounding document markers or trailing newline.
/// </summary>
public static string Format(string value)
{
ArgUtil.NotNull(value, nameof(value));
using var sw = new StringWriter(CultureInfo.InvariantCulture);
// Force LF line breaks; YamlDotNet's Emitter calls WriteLine,
// which would otherwise produce CRLF on Windows and break
// both our document-end stripping below and downstream
// consumers that assume a single line-break convention.
sw.NewLine = "\n";
var emitter = new Emitter(sw);
emitter.Emit(new StreamStart());
emitter.Emit(new DocumentStart(null, null, true));
emitter.Emit(new Scalar(null, null, value, ScalarStyle.Any, true, true));
emitter.Emit(new DocumentEnd(true));
emitter.Emit(new StreamEnd());
string raw = sw.ToString();
// Strip YAML document markers. Emitter elides these for most
// scalars but emits "--- " (with space) for some edge cases
// (e.g. empty strings). Defensively handle "---\n" too.
if (raw.StartsWith("--- ", StringComparison.Ordinal))
{
raw = raw.Substring(4);
}
else if (raw.StartsWith("---\n", StringComparison.Ordinal))
{
raw = raw.Substring(4);
}
raw = raw.TrimEnd('\n');
const string DocEndMarker = "\n...";
if (raw.EndsWith(DocEndMarker, StringComparison.Ordinal))
{
raw = raw.Substring(0, raw.Length - DocEndMarker.Length);
}
return raw;
}
}
}

View File

@@ -338,14 +338,6 @@ namespace GitHub.Runner.Worker
step.ExecutionContext = Root.CreatePostChild(step.DisplayName, IntraActionState, siblingScopeName);
Root.PostJobSteps.Push(step);
// Only consult the DAP debugger when it was actually enabled for this job.
// Without this guard, HostContext.GetService<IDapDebugger>() would auto-
// instantiate the default singleton for every non-debug job, violating the
// "no debugger, no risk" containment property.
if (Global.Debugger?.Enabled == true)
{
HostContext.GetService<Dap.IDapDebugger>().OnPostStepRegistered(step);
}
}
public IExecutionContext CreateChild(
@@ -978,7 +970,8 @@ namespace GitHub.Runner.Worker
Global.WriteDebug = Global.Variables.Step_Debug ?? false;
// Debugger enabled flag (from acquire response).
Global.Debugger = new Dap.DebuggerConfig(message.EnableDebugger, message.DebuggerTunnel);
var overrideDebuggerWelcomeMessage = Global.Variables.GetBoolean(Constants.Runner.Features.OverrideDebuggerWelcomeMessage) ?? false;
Global.Debugger = new Dap.DebuggerConfig(message.EnableDebugger, message.DebuggerTunnel, overrideDebuggerWelcomeMessage, message.DebuggerWelcomeMessage);
// Hook up JobServerQueueThrottling event, we will log warning on server tarpit.
_jobServerQueue.JobServerQueueThrottling += JobServerQueueThrottling_EventReceived;

View File

@@ -230,24 +230,6 @@ namespace GitHub.Runner.Worker
jobContext.JobSteps.Enqueue(step);
}
if (jobContext.Global.Debugger?.Enabled == true)
{
// Only consult the DAP debugger when it was actually enabled for this job.
// Without this guard, HostContext.GetService<IDapDebugger>() would auto-
// instantiate the default singleton for every non-debug job, violating the
// "no debugger, no risk" containment property.
var dapDebugger = HostContext.GetService<Dap.IDapDebugger>();
try
{
await dapDebugger.OnJobStepsInitializedAsync(jobContext.JobSteps, jobContext.PostJobSteps);
}
catch (Exception ex)
{
Trace.Warning("DAP OnJobStepsInitialized error; continuing without DAP view.");
Trace.Error(ex);
}
}
await stepsRunner.RunAsync(jobContext);
}
catch (Exception ex)

View File

@@ -219,18 +219,12 @@ namespace GitHub.Runner.Worker
// Condition is false
Trace.Info("Skipping step due to condition evaluation.");
CompleteStep(step, TaskResult.Skipped, resultCode: conditionTraceWriter.Trace);
// Notify the DAP debugger so any predicted Post-step
// placeholder for this Main step can be marked as
// skipped — otherwise the rendered view leaves a
// stale "Post X" entry for a step that never ran.
dapDebugger?.OnStepCompleted(step);
}
else if (conditionEvaluateError != null)
{
// Condition error
step.ExecutionContext.Error(conditionEvaluateError);
CompleteStep(step, TaskResult.Failed);
dapDebugger?.OnStepCompleted(step);
}
else
{

View File

@@ -267,6 +267,21 @@ namespace GitHub.DistributedTask.Pipelines
set;
}
/// <summary>
/// Optional welcome message shown in the debugger console when a client connects.
/// Only used when the <c>actions_runner_override_debugger_welcome_message</c>
/// feature flag is set to <c>true</c> in the job variables. With the flag set,
/// a non-empty value is shown as-is and a null or empty value suppresses the
/// default welcome message. When the flag is not set, the runner shows its
/// built-in help text and this field is ignored.
/// </summary>
[DataMember(EmitDefaultValue = false)]
public string DebuggerWelcomeMessage
{
get;
set;
}
/// <summary>
/// Gets the workflow-level action dependencies (lockfile entries)
/// </summary>

View File

@@ -1,4 +1,4 @@
using System.Collections.Generic;
using System.Collections.Generic;
using System.IO;
using System.Runtime.Serialization.Json;
using System.Text;
@@ -17,13 +17,13 @@ public sealed class AgentJobRequestMessageL0
// Arrange
var serializer = new DataContractJsonSerializer(typeof(AgentJobRequestMessage));
string jsonWithEnabledDebugger = DoubleQuotify("{'EnableDebugger': true}");
// Act
using var stream = new MemoryStream();
stream.Write(Encoding.UTF8.GetBytes(jsonWithEnabledDebugger));
stream.Position = 0;
var recoveredMessage = serializer.ReadObject(stream) as AgentJobRequestMessage;
// Assert
Assert.NotNull(recoveredMessage);
Assert.True(recoveredMessage.EnableDebugger, "EnableDebugger should be true when JSON contains 'EnableDebugger': true");
@@ -37,13 +37,13 @@ public sealed class AgentJobRequestMessageL0
// Arrange
var serializer = new DataContractJsonSerializer(typeof(AgentJobRequestMessage));
string jsonWithoutDebugger = DoubleQuotify("{'messageType': 'PipelineAgentJobRequest'}");
// Act
using var stream = new MemoryStream();
stream.Write(Encoding.UTF8.GetBytes(jsonWithoutDebugger));
stream.Position = 0;
var recoveredMessage = serializer.ReadObject(stream) as AgentJobRequestMessage;
// Assert
Assert.NotNull(recoveredMessage);
Assert.False(recoveredMessage.EnableDebugger, "EnableDebugger should default to false when JSON field is absent");
@@ -57,13 +57,13 @@ public sealed class AgentJobRequestMessageL0
// Arrange
var serializer = new DataContractJsonSerializer(typeof(AgentJobRequestMessage));
string jsonWithDisabledDebugger = DoubleQuotify("{'EnableDebugger': false}");
// Act
using var stream = new MemoryStream();
stream.Write(Encoding.UTF8.GetBytes(jsonWithDisabledDebugger));
stream.Position = 0;
var recoveredMessage = serializer.ReadObject(stream) as AgentJobRequestMessage;
// Assert
Assert.NotNull(recoveredMessage);
Assert.False(recoveredMessage.EnableDebugger, "EnableDebugger should be false when JSON contains 'EnableDebugger': false");
@@ -161,6 +161,26 @@ public sealed class AgentJobRequestMessageL0
Assert.Empty(recoveredMessage.ActionsDependencies);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Common")]
public void VerifyDebuggerWelcomeMessageRoundTrips()
{
// Arrange
var serializer = new DataContractJsonSerializer(typeof(AgentJobRequestMessage));
string json = DoubleQuotify("{'DebuggerWelcomeMessage': 'Welcome to debugging!'}");
// Act
using var stream = new MemoryStream();
stream.Write(Encoding.UTF8.GetBytes(json));
stream.Position = 0;
var recoveredMessage = serializer.ReadObject(stream) as AgentJobRequestMessage;
// Assert
Assert.NotNull(recoveredMessage);
Assert.Equal("Welcome to debugging!", recoveredMessage.DebuggerWelcomeMessage);
}
private static string DoubleQuotify(string text)
{
return text.Replace('\'', '"');

View File

@@ -236,7 +236,7 @@ namespace GitHub.Runner.Common.Tests.Worker
}
}
private static Mock<IExecutionContext> CreateJobContextWithTunnel(CancellationToken cancellationToken, ushort port, string jobName = null)
private static Mock<IExecutionContext> CreateJobContextWithTunnel(CancellationToken cancellationToken, ushort port, string jobName = null, bool overrideWelcomeMessage = false, string welcomeMessage = null)
{
var tunnel = new GitHub.DistributedTask.Pipelines.DebuggerTunnelInfo
{
@@ -245,7 +245,7 @@ namespace GitHub.Runner.Common.Tests.Worker
HostToken = "test-token",
Port = port
};
var debuggerConfig = new DebuggerConfig(true, tunnel);
var debuggerConfig = new DebuggerConfig(true, tunnel, overrideWelcomeMessage, welcomeMessage);
var jobContext = new Mock<IExecutionContext>();
jobContext.Setup(x => x.CancellationToken).Returns(cancellationToken);
jobContext.Setup(x => x.Global).Returns(new GlobalContext { Debugger = debuggerConfig });
@@ -742,6 +742,8 @@ namespace GitHub.Runner.Common.Tests.Worker
// Read the configurationDone response
await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
// Read the welcome message output event
await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
await waitTask;
// Complete the job — OnJobCompletedAsync pauses when stepping,
@@ -849,6 +851,8 @@ namespace GitHub.Runner.Common.Tests.Worker
Command = "configurationDone"
});
await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
// Read the welcome message output event
await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
await waitTask;
@@ -868,572 +872,222 @@ namespace GitHub.Runner.Common.Tests.Worker
}
}
// ---------------------------------------------------------------------
// 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()
public async Task WelcomeMessageSendsDefaultHelpWhenOverrideDisabled()
{
using (CreateTestContext())
{
var port = GetFreePort();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContextWithTunnel(cts.Token, port, "ci-job");
var jobContext = CreateJobContextWithTunnel(cts.Token, port);
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 _debugger.WaitUntilReadyAsync();
var stream = client.GetStream();
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 SendRequestAsync(stream, new Request
{
await _debugger.StopAsync();
}
Seq = 1,
Type = "request",
Command = "configurationDone"
});
// First message: configurationDone response
var configDoneResponse = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
Assert.Contains("\"command\":\"configurationDone\"", configDoneResponse);
// Second message: welcome output event with default help text
var welcomeMsg = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
Assert.Contains("\"event\":\"output\"", welcomeMsg);
Assert.Contains("\"category\":\"console\"", welcomeMsg);
Assert.Contains("Actions Debug Console", welcomeMsg);
Assert.Contains("help", welcomeMsg);
await _debugger.StopAsync();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task OnPostStepRegistered_EmitsLoadedSourceChangedEvent()
public async Task WelcomeMessageShowsCustomMessageWhenOverrideEnabled()
{
using (CreateTestContext())
{
var port = GetFreePort();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContextWithTunnel(cts.Token, port, "ci-job");
var jobContext = CreateJobContextWithTunnel(cts.Token, port,
overrideWelcomeMessage: true,
welcomeMessage: "Welcome to debugging!");
await _debugger.StartAsync(jobContext.Object);
using var client = await ConnectClientAsync(port);
try
var stream = client.GetStream();
await SendRequestAsync(stream, new Request
{
var stream = client.GetStream();
await SendRequestAsync(stream, new Request { Seq = 1, Type = "request", Command = "configurationDone" });
await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
await _debugger.WaitUntilReadyAsync();
Seq = 1,
Type = "request",
Command = "configurationDone"
});
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));
// First: configurationDone response
var configDoneResponse = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
Assert.Contains("\"command\":\"configurationDone\"", configDoneResponse);
var post = NewActionRunner(GitHub.Runner.Worker.ActionRunStage.Post, "Post Run", "actions/cache", "v3").Object;
_debugger.OnPostStepRegistered(post);
// Second: custom welcome message
var welcomeMsg = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
Assert.Contains("\"event\":\"output\"", welcomeMsg);
Assert.Contains("Welcome to debugging!", welcomeMsg);
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();
}
await _debugger.StopAsync();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task StackTrace_LineUpdatesAsStepsAdvance()
public async Task WelcomeMessageSuppressedWhenOverrideEnabledWithEmptyMessage()
{
using (CreateTestContext())
{
var port = GetFreePort();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContextWithTunnel(cts.Token, port, "ci-job");
var jobContext = CreateJobContextWithTunnel(cts.Token, port,
overrideWelcomeMessage: true,
welcomeMessage: "");
await _debugger.StartAsync(jobContext.Object);
try
using var client = await ConnectClientAsync(port);
var stream = client.GetStream();
await SendRequestAsync(stream, new Request
{
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>());
Seq = 1,
Type = "request",
Command = "configurationDone"
});
_ = _debugger.OnStepStartingAsync(s1, isFirstStep: true);
await Task.Delay(50);
// Read configurationDone response
var configDoneResponse = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
Assert.Contains("\"command\":\"configurationDone\"", configDoneResponse);
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
// Send threads request — if welcome message was suppressed, this
// should be the next response (no output event in between)
await SendRequestAsync(stream, new Request
{
await _debugger.StopAsync();
}
Seq = 2,
Type = "request",
Command = "threads"
});
var threadsResponse = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
Assert.Contains("\"command\":\"threads\"", threadsResponse);
await _debugger.StopAsync();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task WelcomeMessageSuppressedWhenOverrideEnabledWithNullMessage()
{
using (CreateTestContext())
{
var port = GetFreePort();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContextWithTunnel(cts.Token, port,
overrideWelcomeMessage: true,
welcomeMessage: null);
await _debugger.StartAsync(jobContext.Object);
using var client = await ConnectClientAsync(port);
var stream = client.GetStream();
await SendRequestAsync(stream, new Request
{
Seq = 1,
Type = "request",
Command = "configurationDone"
});
// Read configurationDone response
var configDoneResponse = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
Assert.Contains("\"command\":\"configurationDone\"", configDoneResponse);
// Send threads request — if welcome message was suppressed, this
// should be the next response (no output event in between)
await SendRequestAsync(stream, new Request
{
Seq = 2,
Type = "request",
Command = "threads"
});
var threadsResponse = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
Assert.Contains("\"command\":\"threads\"", threadsResponse);
await _debugger.StopAsync();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task WelcomeMessageSentOnlyOnce()
{
using (CreateTestContext())
{
var port = GetFreePort();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var jobContext = CreateJobContextWithTunnel(cts.Token, port);
await _debugger.StartAsync(jobContext.Object);
using var client = await ConnectClientAsync(port);
var stream = client.GetStream();
// First configurationDone
await SendRequestAsync(stream, new Request
{
Seq = 1,
Type = "request",
Command = "configurationDone"
});
var configDoneResponse = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
Assert.Contains("\"command\":\"configurationDone\"", configDoneResponse);
// Welcome message should appear
var welcomeMsg = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
Assert.Contains("\"event\":\"output\"", welcomeMsg);
Assert.Contains("Actions Debug Console", welcomeMsg);
// Second configurationDone — should NOT produce another welcome message
await SendRequestAsync(stream, new Request
{
Seq = 2,
Type = "request",
Command = "configurationDone"
});
var secondResponse = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
Assert.Contains("\"command\":\"configurationDone\"", secondResponse);
// Next message should be threads response, not another welcome output
await SendRequestAsync(stream, new Request
{
Seq = 3,
Type = "request",
Command = "threads"
});
var threadsResponse = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
Assert.Contains("\"command\":\"threads\"", threadsResponse);
await _debugger.StopAsync();
}
}
}

View File

@@ -5,9 +5,12 @@ using System.Threading;
using System.Threading.Tasks;
using GitHub.DistributedTask.Expressions2;
using GitHub.DistributedTask.Pipelines.ContextData;
using GitHub.DistributedTask.WebApi;
using GitHub.Runner.Common.Tests;
using GitHub.Runner.Worker;
using GitHub.Runner.Worker.Container;
using GitHub.Runner.Worker.Dap;
using GitHub.Runner.Worker.Handlers;
using Moq;
using Xunit;
@@ -40,7 +43,8 @@ namespace GitHub.Runner.Common.Tests.Worker
private Mock<IExecutionContext> CreateMockContext(
DictionaryContextData exprValues = null,
IDictionary<string, IDictionary<string, string>> jobDefaults = null)
IDictionary<string, IDictionary<string, string>> jobDefaults = null,
ContainerInfo container = null)
{
var mock = new Mock<IExecutionContext>();
mock.Setup(x => x.ExpressionValues).Returns(exprValues ?? new DictionaryContextData());
@@ -51,6 +55,7 @@ namespace GitHub.Runner.Common.Tests.Worker
PrependPath = new List<string>(),
JobDefaults = jobDefaults
?? new Dictionary<string, IDictionary<string, string>>(StringComparer.OrdinalIgnoreCase),
Container = container,
};
mock.Setup(x => x.Global).Returns(global);
@@ -65,7 +70,7 @@ namespace GitHub.Runner.Common.Tests.Worker
using (CreateTestContext())
{
var command = new RunCommand { Script = "echo hello" };
var result = await _executor.ExecuteRunCommandAsync(command, null, CancellationToken.None);
var result = await _executor.ExecuteRunCommandAsync(command, null, false, CancellationToken.None);
Assert.Equal("error", result.Type);
Assert.Contains("No execution context available", result.Result);
@@ -233,5 +238,101 @@ namespace GitHub.Runner.Common.Tests.Worker
Assert.False(result.ContainsKey("BAZ"));
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void CreateStepHost_NoContainer_ReturnsDefaultStepHost()
{
using (var hc = CreateTestContext())
{
hc.EnqueueInstance<IDefaultStepHost>(new DefaultStepHost());
var context = CreateMockContext();
var result = _executor.CreateStepHost(context.Object, isActionStep: true);
Assert.IsType<DefaultStepHost>(result);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void CreateStepHost_WithContainer_ActionStep_ReturnsContainerStepHost()
{
using (var hc = CreateTestContext())
{
hc.EnqueueInstance<IContainerStepHost>(new ContainerStepHost());
var container = new ContainerInfo { ContainerId = "abc123" };
var context = CreateMockContext(container: container);
var result = _executor.CreateStepHost(context.Object, isActionStep: true);
Assert.IsType<ContainerStepHost>(result);
var containerHost = (ContainerStepHost)result;
Assert.Same(container, containerHost.Container);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void CreateStepHost_WithContainer_InfrastructureStep_ReturnsDefaultStepHost()
{
using (var hc = CreateTestContext())
{
hc.EnqueueInstance<IDefaultStepHost>(new DefaultStepHost());
var container = new ContainerInfo { ContainerId = "abc123" };
var context = CreateMockContext(container: container);
var result = _executor.CreateStepHost(context.Object, isActionStep: false);
Assert.IsType<DefaultStepHost>(result);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void CreateStepHost_ContainerWithoutId_NoHooks_ReturnsDefaultStepHost()
{
using (var hc = CreateTestContext())
{
hc.EnqueueInstance<IDefaultStepHost>(new DefaultStepHost());
// Container exists but hasn't been started yet (no ContainerId)
var container = new ContainerInfo();
var context = CreateMockContext(container: container);
var result = _executor.CreateStepHost(context.Object, isActionStep: true);
Assert.IsType<DefaultStepHost>(result);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void CreateStepHost_ContainerWithoutId_HooksEnabled_ReturnsContainerStepHost()
{
using (var hc = CreateTestContext())
{
hc.EnqueueInstance<IContainerStepHost>(new ContainerStepHost());
// Container hooks need both the feature flag and the env var
Environment.SetEnvironmentVariable("ACTIONS_RUNNER_CONTAINER_HOOKS", "/some/hook/path");
try
{
var container = new ContainerInfo();
var context = CreateMockContext(container: container);
context.Object.Global.Variables = new Variables(
hc,
new Dictionary<string, VariableValue>
{
{ Constants.Runner.Features.AllowRunnerContainerHooks, new VariableValue("true") }
});
var result = _executor.CreateStepHost(context.Object, isActionStep: true);
Assert.IsAssignableFrom<IContainerStepHost>(result);
}
finally
{
Environment.SetEnvironmentVariable("ACTIONS_RUNNER_CONTAINER_HOOKS", null);
}
}
}
}
}

View File

@@ -7,7 +7,6 @@ using GitHub.DistributedTask.Pipelines.ContextData;
using GitHub.DistributedTask.WebApi;
using GitHub.Runner.Worker;
using GitHub.Runner.Worker.Container;
using GitHub.Runner.Worker.Dap;
using GitHub.Runner.Worker.Handlers;
using Moq;
using Xunit;
@@ -406,7 +405,6 @@ namespace GitHub.Runner.Common.Tests.Worker
hc.EnqueueInstance(pagingLogger5.Object);
hc.EnqueueInstance(actionRunner1 as IActionRunner);
hc.EnqueueInstance(actionRunner2 as IActionRunner);
hc.SetSingleton(new Mock<IDapDebugger>().Object);
hc.SetSingleton(jobServerQueue.Object);
var jobContext = new Runner.Worker.ExecutionContext();
@@ -505,7 +503,6 @@ namespace GitHub.Runner.Common.Tests.Worker
hc.EnqueueInstance(pagingLogger5.Object);
hc.EnqueueInstance(actionRunner1 as IActionRunner);
hc.EnqueueInstance(actionRunner2 as IActionRunner);
hc.SetSingleton(new Mock<IDapDebugger>().Object);
hc.SetSingleton(jobServerQueue.Object);
var jobContext = new Runner.Worker.ExecutionContext();
@@ -547,75 +544,6 @@ namespace GitHub.Runner.Common.Tests.Worker
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void RegisterPostJobAction_DebuggerDisabled_DoesNotInvokeDapDebugger()
{
using (TestHostContext hc = CreateTestContext())
{
// Arrange: Create a job request message with EnableDebugger left at the default (false).
TaskOrchestrationPlanReference plan = new();
TimelineReference timeline = new();
Guid jobId = Guid.NewGuid();
string jobName = "some job name";
var jobRequest = new Pipelines.AgentJobRequestMessage(plan, timeline, jobId, jobName, jobName, null, null, null, new Dictionary<string, VariableValue>(), new List<MaskHint>(), new Pipelines.JobResources(), new Pipelines.ContextData.DictionaryContextData(), new Pipelines.WorkspaceOptions(), new List<Pipelines.ActionStep>(), null, null, null, null, null);
jobRequest.Resources.Repositories.Add(new Pipelines.RepositoryResource()
{
Alias = Pipelines.PipelineConstants.SelfAlias,
Id = "github",
Version = "sha1"
});
jobRequest.ContextData["github"] = new Pipelines.ContextData.DictionaryContextData();
var pagingLogger = new Mock<IPagingLogger>();
var jobServerQueue = new Mock<IJobServerQueue>();
jobServerQueue.Setup(x => x.QueueTimelineRecordUpdate(It.IsAny<Guid>(), It.IsAny<TimelineRecord>()));
jobServerQueue.Setup(x => x.QueueWebConsoleLine(It.IsAny<Guid>(), It.IsAny<string>(), It.IsAny<long?>()));
var actionRunner = new ActionRunner();
actionRunner.Initialize(hc);
hc.EnqueueInstance(pagingLogger.Object);
hc.EnqueueInstance(pagingLogger.Object);
hc.EnqueueInstance(pagingLogger.Object);
hc.EnqueueInstance(pagingLogger.Object);
hc.EnqueueInstance(pagingLogger.Object);
hc.EnqueueInstance(pagingLogger.Object);
hc.EnqueueInstance(pagingLogger.Object);
hc.EnqueueInstance(actionRunner as IActionRunner);
// Register a strict mock IDapDebugger. If the production code calls
// ANY method on it, the test fails — proving the containment guard
// short-circuited before HostContext.GetService<IDapDebugger>().
var dapMock = new Mock<IDapDebugger>(MockBehavior.Strict);
hc.SetSingleton(dapMock.Object);
hc.SetSingleton(jobServerQueue.Object);
var jobContext = new Runner.Worker.ExecutionContext();
jobContext.Initialize(hc);
jobContext.InitializeJob(jobRequest, CancellationToken.None);
var action = jobContext.CreateChild(Guid.NewGuid(), "action_1", "action_1", null, null, 0);
var postRunner = hc.CreateService<IActionRunner>();
postRunner.Action = new Pipelines.ActionStep() { Id = Guid.NewGuid(), Name = "post", DisplayName = "Post", Reference = new Pipelines.RepositoryPathReference() { Name = "actions/action" } };
postRunner.Stage = ActionRunStage.Post;
postRunner.Condition = "always()";
postRunner.DisplayName = "post";
// Sanity: ensure the production code path actually believes the debugger is disabled.
Assert.True(jobContext.Global.Debugger == null || jobContext.Global.Debugger.Enabled == false);
// Act.
action.RegisterPostJobStep(postRunner);
// Assert: the debugger was never consulted on the non-debug path.
dapMock.VerifyNoOtherCalls();
Assert.Equal(1, jobContext.PostJobSteps.Count);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]

View File

@@ -426,42 +426,17 @@ namespace GitHub.Runner.Common.Tests.Worker
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void TryMarkSkipped_MarksUnclaimedPlaceholder()
public void Append_RejectsBothStepIdentityAndMatchKey()
{
// Allowing both would orphan the IStep→line mapping the moment
// TryClaim overwrites _stepIdentities[index] for a different
// step, so the API rejects the combination at append time.
var view = new JobExecutionView("j");
var postEntry = new JobExecutionViewEntry(JobExecutionPhase.Post, "Post X", uses: "actions/x@v1");
view.Append(postEntry, stepIdentity: null, matchKey: "k1");
Assert.True(view.TryMarkSkipped("k1"));
Assert.True(postEntry.IsSkipped);
Assert.Contains("(skipped — main step did not execute)", view.Yaml);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void TryMarkSkipped_ReturnsFalseForUnknownKey()
{
var view = new JobExecutionView("j");
Assert.False(view.TryMarkSkipped("nope"));
Assert.DoesNotContain("(skipped", view.Yaml);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void TryMarkSkipped_ReturnsFalseForClaimedPlaceholder()
{
var view = new JobExecutionView("j");
var postEntry = new JobExecutionViewEntry(JobExecutionPhase.Post, "Post X", uses: "actions/x@v1");
view.Append(postEntry, stepIdentity: null, matchKey: "k1");
var step = NewStep("real-post");
Assert.NotNull(view.TryClaim("k1", step));
// Already claimed — must not mark as skipped.
Assert.False(view.TryMarkSkipped("k1"));
Assert.False(postEntry.IsSkipped);
var entry = new JobExecutionViewEntry(JobExecutionPhase.Post, "Post X", uses: "actions/x@v1");
Assert.Throws<ArgumentException>(() =>
view.Append(entry, stepIdentity: NewStep("real"), matchKey: "k1"));
// State unchanged.
Assert.Equal(0, view.EntryCount);
}
}
}

View File

@@ -1,703 +0,0 @@
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();
}
}
}
}
}

View File

@@ -1,6 +1,5 @@
using System;
using System.Collections.Generic;
using System.Linq;
using GitHub.Runner.Worker.Dap;
using Xunit;
@@ -585,33 +584,15 @@ namespace GitHub.Runner.Common.Tests.Worker
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Render_EmitsSkippedAnnotationForMarkedEntry()
public void Render_AlwaysUsesLfLineBreaks()
{
var entry = new JobExecutionViewEntry(JobExecutionPhase.Post, "Post X", uses: "actions/x@v1");
entry.IsSkipped = true;
var result = JobExecutionViewRenderer.Render("j", new[] { entry });
// Annotation is inline on the `- step:` line so subsequent
// entry line numbers stay stable.
Assert.Contains("- step: Post X # (skipped — main step did not execute)\n", result.Yaml);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Render_SkippedAnnotation_DoesNotShiftSubsequentLines()
{
var skipped = new JobExecutionViewEntry(JobExecutionPhase.Post, "Post A", uses: "actions/a@v1");
var following = new JobExecutionViewEntry(JobExecutionPhase.Post, "Post B", uses: "actions/b@v1");
var unmarked = JobExecutionViewRenderer.Render("j", new[] { skipped, following });
skipped.IsSkipped = true;
var marked = JobExecutionViewRenderer.Render("j", new[] { skipped, following });
// Following entry's start line must not move when the prior
// entry gets an inline skipped annotation.
Assert.Equal(unmarked.EntryStartLines[1], marked.EntryStartLines[1]);
// Regression: YamlDotNet's Emitter calls WriteLine, which on
// Windows produces CRLF (the host's Environment.NewLine).
// The renderer's hand-emitted skeleton always uses '\n'; this
// test asserts the scalar formatter doesn't sneak CRLF in.
var entry = new JobExecutionViewEntry(JobExecutionPhase.Main, "with: colon", id: "step-1", uses: "actions/checkout@v4");
var result = JobExecutionViewRenderer.Render("job-1", new[] { entry });
Assert.DoesNotContain("\r", result.Yaml);
}
}
}

View File

@@ -141,7 +141,6 @@ namespace GitHub.Runner.Common.Tests.Worker
hc.SetSingleton(_diagnosticLogManager.Object);
hc.SetSingleton(_jobHookProvider.Object);
hc.SetSingleton(_snapshotOperationProvider.Object);
hc.SetSingleton(new Mock<IDapDebugger>().Object);
hc.EnqueueInstance<IPagingLogger>(_logger.Object); // JobExecutionContext
hc.EnqueueInstance<IPagingLogger>(_logger.Object); // job start hook
hc.EnqueueInstance<IPagingLogger>(_logger.Object); // Initial Job

View File

@@ -1,6 +1,5 @@
using GitHub.DistributedTask.WebApi;
using GitHub.Runner.Worker;
using GitHub.Runner.Worker.Dap;
using Moq;
using System;
using System.Collections.Generic;
@@ -84,7 +83,6 @@ namespace GitHub.Runner.Common.Tests.Worker
hc.SetSingleton(_extensions.Object);
hc.SetSingleton(_temp.Object);
hc.SetSingleton(_diagnosticLogManager.Object);
hc.SetSingleton(new Mock<IDapDebugger>().Object);
hc.EnqueueInstance<IExecutionContext>(_jobEc);
hc.EnqueueInstance<IPagingLogger>(_logger.Object);
hc.EnqueueInstance<IJobExtension>(_jobExtension.Object);
@@ -177,29 +175,5 @@ namespace GitHub.Runner.Common.Tests.Worker
Assert.Equal(TaskResult.Succeeded, _jobEc.Result);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task DebuggerDisabled_DoesNotInvokeDapDebugger()
{
using (TestHostContext hc = CreateTestContext())
{
// Override the lenient IDapDebugger singleton from CreateTestContext
// with a strict mock. If the containment guard fails, the production
// code will call OnJobStepsInitializedAsync and the strict mock will throw.
var dapMock = new Mock<IDapDebugger>(MockBehavior.Strict);
hc.SetSingleton(dapMock.Object);
var message = GetMessage();
// EnableDebugger defaults to false on AgentJobRequestMessage.
Assert.False(message.EnableDebugger);
await _jobRunner.RunAsync(message, _tokenSource.Token);
Assert.Equal(TaskResult.Succeeded, _jobEc.Result);
dapMock.VerifyNoOtherCalls();
}
}
}
}

View File

@@ -1,425 +0,0 @@
using System;
using System.Collections.Generic;
using GitHub.DistributedTask.ObjectTemplating.Tokens;
using GitHub.DistributedTask.Pipelines;
using GitHub.Runner.Worker;
using GitHub.Runner.Worker.Dap;
using Moq;
using Xunit;
namespace GitHub.Runner.Common.Tests.Worker
{
public sealed class StepEntryTranslatorL0
{
private static StringToken Str(string s) => new(null, null, null, s);
private static MappingToken Map(params (string Key, TemplateToken Value)[] pairs)
{
var m = new MappingToken(null, null, null);
foreach (var (k, v) in pairs)
{
m.Add(Str(k), v);
}
return m;
}
private static Mock<IActionRunner> NewActionRunnerMock(
ActionRunStage stage,
string displayName,
ActionStepDefinitionReference reference,
ActionStep actionOverride = null)
{
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(actionOverride ?? new ActionStep
{
Reference = reference,
});
return mock;
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Translate_NullStep_Throws()
{
Assert.Throws<ArgumentNullException>(() =>
StepEntryTranslator.TryTranslate(null));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Translate_JobExtensionRunner_ReturnsNull()
{
var step = new JobExtensionRunner(
runAsync: (_, __) => System.Threading.Tasks.Task.CompletedTask,
condition: null,
displayName: "Set up job",
data: null);
Assert.Null(StepEntryTranslator.TryTranslate(step));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Translate_OtherIStepType_ReturnsNull()
{
var mock = new Mock<IStep>();
mock.SetupGet(x => x.DisplayName).Returns("custom");
Assert.Null(StepEntryTranslator.TryTranslate(mock.Object));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Translate_ActionRunnerPre_ReturnsPreEntry()
{
var reference = new RepositoryPathReference
{
Name = "actions/checkout",
Ref = "v4",
};
var mock = NewActionRunnerMock(ActionRunStage.Pre, "Pre Run actions/checkout@v4", reference);
var entry = StepEntryTranslator.TryTranslate(mock.Object);
Assert.NotNull(entry);
Assert.Equal(JobExecutionPhase.Pre, entry.Phase);
Assert.Equal("Pre Run actions/checkout@v4", entry.DisplayName);
Assert.Equal("actions/checkout@v4", entry.Uses);
Assert.Null(entry.Run);
Assert.Null(entry.SourcePath);
Assert.Equal(0, entry.SourceLine);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Translate_ActionRunnerMain_ReturnsMainEntryWithUses()
{
var reference = new RepositoryPathReference
{
Name = "actions/setup-node",
Path = "subdir",
Ref = "v3",
};
var mock = NewActionRunnerMock(ActionRunStage.Main, "Run actions/setup-node@v3", reference);
var entry = StepEntryTranslator.TryTranslate(mock.Object);
Assert.NotNull(entry);
Assert.Equal(JobExecutionPhase.Main, entry.Phase);
Assert.Equal("actions/setup-node/subdir@v3", entry.Uses);
Assert.Null(entry.Run);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Translate_ActionRunnerMain_ScriptReference_LeavesUsesNull()
{
var mock = NewActionRunnerMock(ActionRunStage.Main, "Run echo hi", new ScriptReference());
var entry = StepEntryTranslator.TryTranslate(mock.Object);
Assert.NotNull(entry);
Assert.Equal(JobExecutionPhase.Main, entry.Phase);
Assert.Null(entry.Uses);
Assert.Null(entry.Run);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Translate_ActionRunnerMain_ContainerReference_UsesImage()
{
var reference = new ContainerRegistryReference { Image = "alpine:3.18" };
var mock = NewActionRunnerMock(ActionRunStage.Main, "Run alpine", reference);
var entry = StepEntryTranslator.TryTranslate(mock.Object);
Assert.NotNull(entry);
Assert.Equal("alpine:3.18", entry.Uses);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Translate_ActionRunnerPost_ReturnsPostEntry()
{
var reference = new RepositoryPathReference { Name = "actions/cache", Ref = "v3" };
var mock = NewActionRunnerMock(ActionRunStage.Post, "Post Run actions/cache@v3", reference);
var entry = StepEntryTranslator.TryTranslate(mock.Object);
Assert.NotNull(entry);
Assert.Equal(JobExecutionPhase.Post, entry.Phase);
Assert.Equal("Post Run actions/cache@v3", entry.DisplayName);
Assert.Equal("actions/cache@v3", entry.Uses);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Translate_ActionRunner_NullAction_LeavesUsesNull()
{
var mock = new Mock<IActionRunner>();
mock.SetupGet(x => x.Stage).Returns(ActionRunStage.Main);
mock.SetupGet(x => x.DisplayName).Returns("anonymous");
mock.SetupGet(x => x.Action).Returns((ActionStep)null);
var entry = StepEntryTranslator.TryTranslate(mock.Object);
Assert.NotNull(entry);
Assert.Equal("anonymous", entry.DisplayName);
Assert.Null(entry.Uses);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Translate_ActionStep_ExtractsWith()
{
var reference = new RepositoryPathReference { Name = "actions/cache", Ref = "v5" };
var action = new ActionStep
{
Reference = reference,
Inputs = Map(("path", Str("prime-numbers")), ("key", Str("k"))),
};
var mock = NewActionRunnerMock(ActionRunStage.Main, "Cache", reference, action);
var entry = StepEntryTranslator.TryTranslate(mock.Object);
Assert.NotNull(entry);
Assert.NotNull(entry.WithYaml);
Assert.Contains("path: prime-numbers", entry.WithYaml);
Assert.Contains("key: k", entry.WithYaml);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Translate_ActionStep_PreservesExpressionInWith()
{
var reference = new RepositoryPathReference { Name = "actions/cache", Ref = "v5" };
var action = new ActionStep
{
Reference = reference,
Inputs = Map(("key", Str("${{ runner.os }}-primes"))),
};
var mock = NewActionRunnerMock(ActionRunStage.Main, "Cache", reference, action);
var entry = StepEntryTranslator.TryTranslate(mock.Object);
Assert.NotNull(entry);
Assert.Contains("${{ runner.os }}-primes", entry.WithYaml);
Assert.DoesNotContain("Linux", entry.WithYaml);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Translate_RunStep_ExtractsScript()
{
var action = new ActionStep
{
Reference = new ScriptReference(),
Inputs = Map(("script", Str("echo hi"))),
};
var mock = NewActionRunnerMock(ActionRunStage.Main, "Run echo", new ScriptReference(), action);
var entry = StepEntryTranslator.TryTranslate(mock.Object);
Assert.NotNull(entry);
Assert.Null(entry.Uses);
Assert.Equal("echo hi", entry.Run);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Translate_RunStep_ExtractsShellAndWorkingDirectory()
{
var action = new ActionStep
{
Reference = new ScriptReference(),
Inputs = Map(
("script", Str("npm test")),
("shell", Str("bash")),
("working-directory", Str("./api"))),
};
var mock = NewActionRunnerMock(ActionRunStage.Main, "Run", new ScriptReference(), action);
var entry = StepEntryTranslator.TryTranslate(mock.Object);
Assert.NotNull(entry);
Assert.Equal("npm test", entry.Run);
Assert.Equal("bash", entry.Shell);
Assert.Equal("./api", entry.WorkingDirectory);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Translate_ActionStep_FiltersRunStepKeysFromWith()
{
// Defensive: an action step's Inputs should not contain
// run-step internal keys, but if it did, they must not
// surface in the with: rendering.
var reference = new RepositoryPathReference { Name = "a/b", Ref = "v1" };
var action = new ActionStep
{
Reference = reference,
Inputs = Map(
("mode", Str("ci")),
("script", Str("leak")),
("shell", Str("leak")),
("working-directory", Str("leak"))),
};
var mock = NewActionRunnerMock(ActionRunStage.Main, "Run", reference, action);
var entry = StepEntryTranslator.TryTranslate(mock.Object);
Assert.NotNull(entry);
Assert.NotNull(entry.WithYaml);
Assert.Contains("mode: ci", entry.WithYaml);
Assert.DoesNotContain("leak", entry.WithYaml);
Assert.DoesNotContain("script", entry.WithYaml);
Assert.DoesNotContain("shell", entry.WithYaml);
Assert.DoesNotContain("working-directory", entry.WithYaml);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Translate_ActionStep_OmitsEmptyEnv()
{
var reference = new RepositoryPathReference { Name = "a/b", Ref = "v1" };
var action = new ActionStep
{
Reference = reference,
Environment = new MappingToken(null, null, null),
};
var mock = NewActionRunnerMock(ActionRunStage.Main, "Run", reference, action);
var entry = StepEntryTranslator.TryTranslate(mock.Object);
Assert.NotNull(entry);
Assert.Null(entry.EnvYaml);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Translate_ActionStep_ExtractsEnv()
{
var reference = new RepositoryPathReference { Name = "a/b", Ref = "v1" };
var action = new ActionStep
{
Reference = reference,
Environment = Map(("NODE_ENV", Str("production"))),
};
var mock = NewActionRunnerMock(ActionRunStage.Main, "Run", reference, action);
var entry = StepEntryTranslator.TryTranslate(mock.Object);
Assert.NotNull(entry);
Assert.NotNull(entry.EnvYaml);
Assert.Contains("NODE_ENV: production", entry.EnvYaml);
}
[Theory]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("__1")]
[InlineData("__123")]
public void Translate_FiltersAutoGeneratedId(string contextName)
{
var reference = new RepositoryPathReference { Name = "a/b", Ref = "v1" };
var action = new ActionStep
{
Reference = reference,
ContextName = contextName,
};
var mock = NewActionRunnerMock(ActionRunStage.Main, "Run", reference, action);
var entry = StepEntryTranslator.TryTranslate(mock.Object);
Assert.NotNull(entry);
Assert.Null(entry.Id);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Translate_PreservesUserId()
{
var reference = new RepositoryPathReference { Name = "a/b", Ref = "v1" };
var action = new ActionStep
{
Reference = reference,
ContextName = "cache-primes",
};
var mock = NewActionRunnerMock(ActionRunStage.Main, "Cache", reference, action);
var entry = StepEntryTranslator.TryTranslate(mock.Object);
Assert.NotNull(entry);
Assert.Equal("cache-primes", entry.Id);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Translate_ActionStep_ExtractsCondition()
{
var reference = new RepositoryPathReference { Name = "a/b", Ref = "v1" };
var action = new ActionStep
{
Reference = reference,
Condition = "always()",
};
var mock = NewActionRunnerMock(ActionRunStage.Main, "Run", reference, action);
var entry = StepEntryTranslator.TryTranslate(mock.Object);
Assert.NotNull(entry);
Assert.Equal("always()", entry.If);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Translate_PreEntry_OmitsUserParams()
{
// Pre entries stay minimal — they reference the same Action as
// Main, and duplicating params adds noise.
var reference = new RepositoryPathReference { Name = "a/b", Ref = "v1" };
var action = new ActionStep
{
Reference = reference,
ContextName = "user-id",
Condition = "always()",
Environment = Map(("X", Str("y"))),
Inputs = Map(("k", Str("v"))),
};
var mock = NewActionRunnerMock(ActionRunStage.Pre, "Pre a/b@v1", reference, action);
var entry = StepEntryTranslator.TryTranslate(mock.Object);
Assert.NotNull(entry);
Assert.Equal(JobExecutionPhase.Pre, entry.Phase);
Assert.Null(entry.Id);
Assert.Null(entry.If);
Assert.Null(entry.EnvYaml);
Assert.Null(entry.WithYaml);
}
}
}

View File

@@ -1,155 +0,0 @@
using GitHub.DistributedTask.ObjectTemplating.Tokens;
using GitHub.Runner.Worker.Dap;
using Xunit;
namespace GitHub.Runner.Common.Tests.Worker
{
public sealed class TemplateTokenYamlAdapterL0
{
private static StringToken Str(string s) => new(null, null, null, s);
private static BooleanToken Bool(bool b) => new(null, null, null, b);
private static NumberToken Num(double n) => new(null, null, null, n);
private static BasicExpressionToken Expr(string s) => new(null, null, null, s);
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Serialize_StringScalar()
{
Assert.Equal("hello", TemplateTokenYamlAdapter.Serialize(Str("hello"), 0));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Serialize_BooleanScalar()
{
Assert.Equal("true", TemplateTokenYamlAdapter.Serialize(Bool(true), 0));
Assert.Equal("false", TemplateTokenYamlAdapter.Serialize(Bool(false), 0));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Serialize_NumberScalar()
{
Assert.Equal("10", TemplateTokenYamlAdapter.Serialize(Num(10), 0));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Serialize_NullToken_RendersAsNull()
{
Assert.Equal("null", TemplateTokenYamlAdapter.Serialize(null, 0));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Serialize_PreservesBasicExpression()
{
var token = Expr("runner.os");
string yaml = TemplateTokenYamlAdapter.Serialize(token, 0);
Assert.Contains("${{ runner.os }}", yaml);
Assert.DoesNotContain("Linux", yaml);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Serialize_PreservesCompositeExpressionInStringToken()
{
// Composite strings like `${{ runner.os }}-primes` are parsed
// as a StringToken whose value is exactly that literal. The
// adapter must round-trip the literal unchanged.
var token = Str("${{ runner.os }}-primes");
string yaml = TemplateTokenYamlAdapter.Serialize(token, 0);
Assert.Contains("${{ runner.os }}-primes", yaml);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Serialize_NestedMapping()
{
var inner = new MappingToken(null, null, null);
inner.Add(Str("b"), Num(1));
inner.Add(Str("c"), Expr("x"));
var outer = new MappingToken(null, null, null);
outer.Add(Str("a"), inner);
string yaml = TemplateTokenYamlAdapter.Serialize(outer, 0);
Assert.Contains("a:", yaml);
Assert.Contains("b: 1", yaml);
Assert.Contains("c: ${{ x }}", yaml);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Serialize_EmptyMapping()
{
var token = new MappingToken(null, null, null);
string yaml = TemplateTokenYamlAdapter.Serialize(token, 0);
Assert.Equal("{}", yaml);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Serialize_EmptySequence()
{
var token = new SequenceToken(null, null, null);
string yaml = TemplateTokenYamlAdapter.Serialize(token, 0);
Assert.Equal("[]", yaml);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Serialize_MultilineString_UsesBlockScalar()
{
var token = Str("line1\nline2\nline3");
string yaml = TemplateTokenYamlAdapter.Serialize(token, 0);
// Block-literal indicator `|` appears for multi-line scalars.
Assert.Contains("|", yaml);
Assert.Contains("line1", yaml);
Assert.Contains("line2", yaml);
Assert.Contains("line3", yaml);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Serialize_IndentLevel_PrefixesNonEmptyLines()
{
var map = new MappingToken(null, null, null);
map.Add(Str("k1"), Str("v1"));
map.Add(Str("k2"), Str("v2"));
string yaml = TemplateTokenYamlAdapter.Serialize(map, indentSpaces: 4);
foreach (var line in yaml.Split('\n'))
{
if (line.Length > 0)
{
Assert.StartsWith(" ", line);
}
}
Assert.Contains("k1: v1", yaml);
Assert.Contains("k2: v2", yaml);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Serialize_NoTrailingNewline()
{
var token = Str("hello");
string yaml = TemplateTokenYamlAdapter.Serialize(token, 0);
Assert.False(yaml.EndsWith("\n"));
}
}
}

View File

@@ -0,0 +1,119 @@
using System;
using System.Collections.Generic;
using GitHub.Runner.Worker.Dap;
using Xunit;
using YamlDotNet.Serialization;
namespace GitHub.Runner.Common.Tests.Worker
{
public sealed class YamlScalarFormatterL0
{
private static readonly IDeserializer Deserializer = new DeserializerBuilder().Build();
// Embed the formatter output inside a minimal YAML mapping and
// round-trip through YamlDotNet, asserting the parsed value equals
// the original input. Decouples assertions from the emitter's
// quoting choices (plain vs single- vs double-quoted).
private static void AssertRoundTrips(string value)
{
string scalar = YamlScalarFormatter.Format(value);
string yaml = $"k: {scalar}\n";
Dictionary<string, object> doc;
try
{
doc = Deserializer.Deserialize<Dictionary<string, object>>(yaml);
}
catch (Exception ex)
{
throw new Xunit.Sdk.XunitException(
$"Formatted scalar did not round-trip as valid YAML.\nInput: '{value}'\nFormatted: '{scalar}'\nFull YAML:\n{yaml}\nError: {ex}");
}
Assert.NotNull(doc);
Assert.True(doc.ContainsKey("k"), $"missing key in parsed doc. Formatted: '{scalar}'");
Assert.Equal(value, doc["k"] as string);
}
[Theory]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
[InlineData("hello")]
[InlineData("with: colon")]
[InlineData("with#hash")]
[InlineData(" leading")]
[InlineData("trailing ")]
[InlineData("a\"b")]
[InlineData("a\\b")]
[InlineData("@at")]
[InlineData("*star")]
[InlineData("&amp")]
[InlineData("?question")]
[InlineData("!exclaim")]
[InlineData("- dash")]
[InlineData("{brace}")]
[InlineData("[bracket]")]
public void Format_RoundTripsThroughYamlDeserializer(string value)
{
// The formatter must produce output that, embedded under a key,
// parses back to exactly the input. The emitter is free to
// pick plain, single-quoted, or double-quoted style.
AssertRoundTrips(value);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Format_PlainAscii_NoQuotingNeeded()
{
// Sanity check that the simple case stays plain.
Assert.Equal("hello", YamlScalarFormatter.Format("hello"));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Format_NoTrailingNewline()
{
Assert.False(YamlScalarFormatter.Format("hello").EndsWith("\n"));
Assert.False(YamlScalarFormatter.Format("with: colon").EndsWith("\n"));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Format_NoDocumentMarkers()
{
// The emitter wraps the scalar in a document; the formatter
// must strip both `--- ` (with space) and `---\n` (on its
// own line) prefixes plus the `\n...` suffix.
Assert.DoesNotContain("---", YamlScalarFormatter.Format("hello"));
Assert.DoesNotContain("...", YamlScalarFormatter.Format("hello"));
// Empty string is one of the cases where the emitter does
// produce a document marker by default.
Assert.DoesNotContain("---", YamlScalarFormatter.Format(""));
Assert.DoesNotContain("...", YamlScalarFormatter.Format(""));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Format_AlwaysUsesLfLineBreaks()
{
// Regression: YamlDotNet's Emitter calls WriteLine, which on
// Windows produces CRLF (the host's Environment.NewLine).
// Format must force LF so the output round-trips regardless
// of platform.
Assert.DoesNotContain('\r', YamlScalarFormatter.Format("hello"));
Assert.DoesNotContain('\r', YamlScalarFormatter.Format("with: colon"));
Assert.DoesNotContain('\r', YamlScalarFormatter.Format(""));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void Format_NullValue_Throws()
{
Assert.Throws<ArgumentNullException>(() => YamlScalarFormatter.Format(null));
}
}
}

View File

@@ -17,7 +17,7 @@ LAYOUT_DIR="$SCRIPT_DIR/../_layout"
DOWNLOAD_DIR="$SCRIPT_DIR/../_downloads/netcore2x"
PACKAGE_DIR="$SCRIPT_DIR/../_package"
DOTNETSDK_ROOT="$SCRIPT_DIR/../_dotnetsdk"
DOTNETSDK_VERSION="8.0.420"
DOTNETSDK_VERSION="8.0.421"
DOTNETSDK_INSTALLDIR="$DOTNETSDK_ROOT/$DOTNETSDK_VERSION"
RUNNER_VERSION=$(cat runnerversion)

View File

@@ -1,5 +1,5 @@
{
"sdk": {
"version": "8.0.420"
"version": "8.0.421"
}
}