mirror of
https://github.com/actions/runner.git
synced 2026-07-04 11:42:21 +08:00
Compare commits
16 Commits
dependabot
...
dap-execut
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88694b1d60 | ||
|
|
31c635f8a9 | ||
|
|
24f8b5460c | ||
|
|
b81d769f93 | ||
|
|
da2376ebaa | ||
|
|
c4a209feeb | ||
|
|
54317aa23e | ||
|
|
754428c272 | ||
|
|
477826132b | ||
|
|
5c49625758 | ||
|
|
3ff2186ec0 | ||
|
|
7c0b271d2e | ||
|
|
0b3b8e0ba7 | ||
|
|
ae2896c551 | ||
|
|
ebf33710e8 | ||
|
|
a1ccd22030 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -27,4 +27,5 @@ TestResults
|
||||
TestLogs
|
||||
.DS_Store
|
||||
.mono
|
||||
**/*.DotSettings.user
|
||||
**/*.DotSettings.user
|
||||
**/*.lscache
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ NODE_ALPINE_URL=https://github.com/actions/alpine_nodejs/releases/download
|
||||
# When you update Node versions you must also create a new release of alpine_nodejs at that updated version.
|
||||
# Follow the instructions here: https://github.com/actions/alpine_nodejs?tab=readme-ov-file#getting-started
|
||||
NODE20_VERSION="20.20.2"
|
||||
NODE24_VERSION="24.15.0"
|
||||
NODE24_VERSION="24.16.0"
|
||||
|
||||
get_abs_path() {
|
||||
# exploits the fact that pwd will print abs path when no args
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -16,19 +16,10 @@ using Microsoft.DevTunnels.Connections;
|
||||
using Microsoft.DevTunnels.Contracts;
|
||||
using Microsoft.DevTunnels.Management;
|
||||
using Newtonsoft.Json;
|
||||
using Pipelines = GitHub.DistributedTask.Pipelines;
|
||||
|
||||
namespace GitHub.Runner.Worker.Dap
|
||||
{
|
||||
/// <summary>
|
||||
/// Stores information about a completed step for stack trace display.
|
||||
/// </summary>
|
||||
internal sealed class CompletedStepInfo
|
||||
{
|
||||
public string DisplayName { get; set; }
|
||||
public TaskResult? Result { get; set; }
|
||||
public int FrameId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Single public facade for the Debug Adapter Protocol subsystem.
|
||||
/// Owns the full transport, handshake, step-level pauses, variable
|
||||
@@ -51,8 +42,12 @@ namespace GitHub.Runner.Worker.Dap
|
||||
// Frame ID for the current step (always 1)
|
||||
private const int _currentFrameId = 1;
|
||||
|
||||
// Frame IDs for completed steps start at 1000
|
||||
private const int _completedFrameIdBase = 1000;
|
||||
// Frame ID for the static "job" frame anchored at line 1 of the execution view.
|
||||
private const int _jobFrameId = 2;
|
||||
|
||||
// MVP serves a single synthesized source per session (the job's execution view).
|
||||
// Stable session-scoped ID 1; future sources (composite step-in) will use higher IDs.
|
||||
private const int _executionViewSourceReference = 1;
|
||||
|
||||
private TcpListener _listener;
|
||||
private TcpClient _client;
|
||||
@@ -63,6 +58,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;
|
||||
@@ -92,11 +88,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;
|
||||
// Set true once OnJobCompletedAsync begins its inspection pause.
|
||||
// While set, HandleStackTrace surfaces the synthetic "Complete job"
|
||||
// frame so the client highlights the cleanup line.
|
||||
private bool _jobCompleted;
|
||||
|
||||
// Client connection tracking for reconnection support
|
||||
private volatile bool _isClientConnected;
|
||||
@@ -107,6 +103,8 @@ namespace GitHub.Runner.Worker.Dap
|
||||
// REPL command executor for run() commands
|
||||
private DapReplExecutor _replExecutor;
|
||||
|
||||
private JobExecutionView _executionView;
|
||||
|
||||
public bool IsActive =>
|
||||
_state == DapSessionState.Ready ||
|
||||
_state == DapSessionState.Paused ||
|
||||
@@ -251,6 +249,10 @@ namespace GitHub.Runner.Worker.Dap
|
||||
{
|
||||
if (_jobContext != null)
|
||||
{
|
||||
lock (_stateLock)
|
||||
{
|
||||
_jobCompleted = true;
|
||||
}
|
||||
Trace.Info("Job completed — pausing for inspection");
|
||||
SendStoppedEvent("completed", "Job completed — inspect variables before the session ends.");
|
||||
|
||||
@@ -259,7 +261,8 @@ namespace GitHub.Runner.Worker.Dap
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Warning($"DAP job-completed pause error: {ex.Message}");
|
||||
Trace.Warning("DAP job-completed pause error.");
|
||||
Trace.Error(ex);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,7 +272,8 @@ namespace GitHub.Runner.Worker.Dap
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Warning($"DAP OnJobCompleted error: {ex.Message}");
|
||||
Trace.Warning("DAP OnJobCompleted error.");
|
||||
Trace.Error(ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -386,7 +390,8 @@ namespace GitHub.Runner.Worker.Dap
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Warning($"DAP OnStepStarting error: {ex.Message}");
|
||||
Trace.Warning("DAP OnStepStarting error.");
|
||||
Trace.Error(ex);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -399,10 +404,7 @@ namespace GitHub.Runner.Worker.Dap
|
||||
|
||||
try
|
||||
{
|
||||
var result = step.ExecutionContext?.Result;
|
||||
Trace.Info("Step completed");
|
||||
|
||||
// Add to completed steps list for stack trace
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (_state != DapSessionState.Ready &&
|
||||
@@ -412,20 +414,336 @@ namespace GitHub.Runner.Worker.Dap
|
||||
return;
|
||||
}
|
||||
|
||||
_completedSteps.Add(new CompletedStepInfo
|
||||
// Clear current-step ref if it matches; otherwise leave alone
|
||||
// (defensive — OnStepStartingAsync may have already advanced it).
|
||||
if (ReferenceEquals(_currentStep, step))
|
||||
{
|
||||
DisplayName = step.DisplayName,
|
||||
Result = result,
|
||||
FrameId = _nextCompletedFrameId++
|
||||
});
|
||||
_currentStep = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Warning($"DAP OnStepCompleted error: {ex.Message}");
|
||||
Trace.Warning("DAP OnStepCompleted error.");
|
||||
Trace.Error(ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot of the current job execution view, or null if it has not
|
||||
/// been built yet (debugger inactive, or InitializeJob has not yet
|
||||
/// signalled). Phase 2c will consume this for DAP source/stack-trace
|
||||
/// responses.
|
||||
/// </summary>
|
||||
internal JobExecutionView ExecutionView
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_stateLock)
|
||||
{
|
||||
return _executionView;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task OnJobStepsInitializedAsync(IEnumerable<IStep> mainQueue, IEnumerable<IStep> initialPostStack)
|
||||
{
|
||||
if (!IsActive)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
IExecutionContext jobContext;
|
||||
lock (_stateLock)
|
||||
{
|
||||
jobContext = _jobContext;
|
||||
}
|
||||
|
||||
string jobId = jobContext?.GetGitHubContext("job");
|
||||
if (string.IsNullOrWhiteSpace(jobId))
|
||||
{
|
||||
jobId = "job";
|
||||
}
|
||||
|
||||
// Materialize mainQueue once so we can iterate it twice
|
||||
// (once for entries, once for the post-step predictor).
|
||||
var mainSteps = mainQueue == null ? new List<IStep>() : new List<IStep>(mainQueue);
|
||||
|
||||
var entries = new List<(JobExecutionViewEntry entry, IStep stepIdentity)>();
|
||||
foreach (var step in mainSteps)
|
||||
{
|
||||
var entry = StepEntryTranslator.TryTranslate(step);
|
||||
if (entry != null)
|
||||
{
|
||||
entries.Add((entry, step));
|
||||
}
|
||||
}
|
||||
// Stack<T>.GetEnumerator() yields items in LIFO order — the
|
||||
// same order callers will pop them. We materialize them into
|
||||
// the view in that pop order so post-step entries appear in
|
||||
// execution order.
|
||||
if (initialPostStack != null)
|
||||
{
|
||||
foreach (var step in initialPostStack)
|
||||
{
|
||||
var entry = StepEntryTranslator.TryTranslate(step);
|
||||
if (entry != null)
|
||||
{
|
||||
entries.Add((entry, step));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var view = new JobExecutionView(jobId);
|
||||
if (entries.Count > 0)
|
||||
{
|
||||
view.AppendRange(entries);
|
||||
}
|
||||
|
||||
// Predict Post-step placeholders for actions that declare
|
||||
// HasPost in their action manifest. Walking Pre+Main runners
|
||||
// in declaration order, then prepending each prediction so
|
||||
// the rendered post section matches the runner's LIFO
|
||||
// post-execution order (the runner's post stack pops in
|
||||
// reverse-registration order). Wrapped in a try/catch so a
|
||||
// missing IActionManager or LoadAction failure cannot
|
||||
// prevent the view from being published.
|
||||
try
|
||||
{
|
||||
PredictPostPlaceholders(jobContext, mainSteps, view);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Warning("DAP predictor: predicting post placeholders failed; continuing without predictions.");
|
||||
Trace.Error(ex);
|
||||
}
|
||||
|
||||
lock (_stateLock)
|
||||
{
|
||||
_executionView = view;
|
||||
}
|
||||
|
||||
Trace.Info($"DAP execution view initialized with {view.EntryCount} entries.");
|
||||
SendLoadedSourceEvent("new");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Warning("DAP OnJobStepsInitialized error.");
|
||||
Trace.Error(ex);
|
||||
}
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
public void OnPostStepRegistered(IStep step)
|
||||
{
|
||||
if (!IsActive || step == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
JobExecutionView view;
|
||||
lock (_stateLock)
|
||||
{
|
||||
view = _executionView;
|
||||
}
|
||||
|
||||
if (view == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to claim a previously-predicted placeholder. When
|
||||
// OnJobStepsInitializedAsync ran, we walked the Pre+Main
|
||||
// queue and synthesized a Post placeholder for every action
|
||||
// whose manifest declared HasPost. If this registration
|
||||
// matches one of those placeholders by Action.Id, claim it
|
||||
// in place — no view growth, no `loadedSource changed`
|
||||
// event needed.
|
||||
if (step is IActionRunner postRunner && postRunner.Action != null)
|
||||
{
|
||||
var matchKey = MatchKeyFor(postRunner.Action.Id);
|
||||
if (view.TryClaim(matchKey, step).HasValue)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Unpredicted path: composite-action JIT post discovery,
|
||||
// container hooks, or any other registration we did not
|
||||
// foresee at view-build time. Fall back to append + notify
|
||||
// clients via `loadedSource changed`.
|
||||
var entry = StepEntryTranslator.TryTranslate(step);
|
||||
if (entry == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
view.Append(entry, step);
|
||||
SendLoadedSourceEvent("changed");
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
// Step already registered — RegisterPostJobStep tolerates
|
||||
// duplicate registrations in some workflow shapes; mirror
|
||||
// that semantics here so we don't propagate.
|
||||
Trace.Info($"DAP OnPostStepRegistered: duplicate step ignored ({ex.Message}).");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Warning("DAP OnPostStepRegistered error.");
|
||||
Trace.Error(ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Walks <paramref name="mainSteps"/> (the queue of Pre+Main
|
||||
/// IActionRunners produced by JobRunner) and synthesizes a Post
|
||||
/// placeholder entry on <paramref name="view"/> for every action
|
||||
/// whose manifest declares <c>HasPost = true</c>.
|
||||
///
|
||||
/// Conditions mirror <c>ActionRunner.RunAsync</c> exactly:
|
||||
/// the runner is in Pre or Main stage, the action is a
|
||||
/// <see cref="Pipelines.RepositoryPathReference"/> that is NOT the
|
||||
/// self-repository alias, the action is not a script, and the
|
||||
/// resolved <see cref="Definition"/> reports HasPost.
|
||||
///
|
||||
/// Predictions are collected in declaration order, then APPENDED
|
||||
/// in reverse so the rendered post section mirrors the runner's
|
||||
/// LIFO post-execution order (the runner's post stack pops in
|
||||
/// reverse-registration order — see ExecutionContext.RegisterPostJobStep).
|
||||
/// </summary>
|
||||
private void PredictPostPlaceholders(IExecutionContext jobContext, IReadOnlyList<IStep> mainSteps, JobExecutionView view)
|
||||
{
|
||||
if (jobContext == null || mainSteps == null || mainSteps.Count == 0 || view == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
IActionManager actionManager;
|
||||
try
|
||||
{
|
||||
actionManager = HostContext.GetService<IActionManager>();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Info($"DAP predictor: IActionManager unavailable ({ex.Message}); skipping post-step prediction.");
|
||||
return;
|
||||
}
|
||||
|
||||
var predictions = new List<(JobExecutionViewEntry entry, string matchKey)>();
|
||||
var seenActionIds = new HashSet<Guid>();
|
||||
|
||||
foreach (var step in mainSteps)
|
||||
{
|
||||
if (step is not IActionRunner runner)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (runner.Stage == ActionRunStage.Post)
|
||||
{
|
||||
// Post entries are already seeded from initialPostStack.
|
||||
continue;
|
||||
}
|
||||
var action = runner.Action;
|
||||
if (action == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// ActionRunner.cs:113 — Post only created when the action is
|
||||
// a RepositoryPathReference that is not the self-repository
|
||||
// alias and not a script.
|
||||
if (action.Reference is not Pipelines.RepositoryPathReference repoRef)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (string.Equals(repoRef.RepositoryType, Pipelines.PipelineConstants.SelfAlias, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// (A RepositoryPathReference is never a script — script
|
||||
// run-steps surface as a different ActionStepDefinitionReference
|
||||
// subclass, so the cast above already filtered them out.)
|
||||
// Dedupe by Action.Id: the runtime dedups via
|
||||
// Root.StepsWithPostRegistered.Add(actionRunner.Action.Id),
|
||||
// so two steps referencing the same Action.Id only ever
|
||||
// register one Post. Mirror that here so we don't synthesize
|
||||
// two placeholders for one future registration.
|
||||
if (!seenActionIds.Add(action.Id))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
Definition definition;
|
||||
try
|
||||
{
|
||||
definition = actionManager.LoadAction(jobContext, action);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Info($"DAP predictor: LoadAction failed for {repoRef.Name} ({ex.Message}); skipping prediction.");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (definition?.Data?.Execution?.HasPost != true)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Compute the Post display name exactly as ActionRunner does
|
||||
// when it constructs the Post IActionRunner (ActionRunner.cs:115-122).
|
||||
var displayName = runner.DisplayName;
|
||||
if (string.IsNullOrEmpty(displayName))
|
||||
{
|
||||
displayName = "step";
|
||||
}
|
||||
if (runner.Stage == ActionRunStage.Pre &&
|
||||
displayName.StartsWith("Pre ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
displayName = displayName.Substring("Pre ".Length);
|
||||
}
|
||||
var postDisplayName = $"Post {displayName}";
|
||||
|
||||
var entry = new JobExecutionViewEntry(
|
||||
phase: JobExecutionPhase.Post,
|
||||
displayName: postDisplayName,
|
||||
uses: StepEntryTranslator.FormatActionReference(action.Reference));
|
||||
|
||||
predictions.Add((entry, MatchKeyFor(action.Id)));
|
||||
}
|
||||
|
||||
// Reverse declaration order so the rendered post section
|
||||
// matches the LIFO order in which the runner will pop posts.
|
||||
predictions.Reverse();
|
||||
|
||||
foreach (var (entry, key) in predictions)
|
||||
{
|
||||
try
|
||||
{
|
||||
view.Append(entry, stepIdentity: null, matchKey: key);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Warning("DAP predictor: failed to append Post placeholder; skipping.");
|
||||
Trace.Error(ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stable, opaque key derived from an action's Pipelines.ActionStep.Id.
|
||||
// All IActionRunner instances for the same action (Pre/Main/Post)
|
||||
// share the same Action reference (see ActionRunner.cs:131), so the
|
||||
// Id is constant across phases and is the right join key.
|
||||
private static string MatchKeyFor(Guid actionId) =>
|
||||
$"post:{actionId:N}";
|
||||
|
||||
internal async Task HandleMessageAsync(string messageJson, CancellationToken cancellationToken)
|
||||
{
|
||||
Request request = null;
|
||||
@@ -467,6 +785,8 @@ namespace GitHub.Runner.Worker.Dap
|
||||
"next" => HandleNext(request),
|
||||
"setBreakpoints" => HandleSetBreakpoints(request),
|
||||
"setExceptionBreakpoints" => HandleSetExceptionBreakpoints(request),
|
||||
"source" => HandleSource(request),
|
||||
"loadedSources" => HandleLoadedSources(request),
|
||||
"completions" => HandleCompletions(request),
|
||||
"stepIn" => CreateResponse(request, false, "Step In is not supported. Actions jobs debug at the step level - use 'next' to advance to the next step.", body: null),
|
||||
"stepOut" => CreateResponse(request, false, "Step Out is not supported. Actions jobs debug at the step level - use 'continue' to resume.", body: null),
|
||||
@@ -490,6 +810,11 @@ namespace GitHub.Runner.Worker.Dap
|
||||
});
|
||||
Trace.Info("Sent initialized event");
|
||||
}
|
||||
|
||||
if (request.Command == "configurationDone")
|
||||
{
|
||||
SendWelcomeMessage();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -508,6 +833,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
|
||||
@@ -818,9 +1144,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 pauseOnNextStep;
|
||||
bool shouldPause;
|
||||
CancellationToken cancellationToken;
|
||||
lock (_stateLock)
|
||||
{
|
||||
@@ -832,18 +1186,14 @@ namespace GitHub.Runner.Worker.Dap
|
||||
}
|
||||
|
||||
_currentStep = step;
|
||||
_currentStepIndex = _completedSteps.Count;
|
||||
pauseOnNextStep = _pauseOnNextStep;
|
||||
cancellationToken = _jobContext?.CancellationToken ?? CancellationToken.None;
|
||||
shouldPause = ShouldPauseBefore(step, isFirstStep);
|
||||
}
|
||||
|
||||
// Reset variable references so stale nested refs from the
|
||||
// previous step are not served to the client.
|
||||
_variableProvider?.Reset();
|
||||
|
||||
// Determine if we should pause
|
||||
bool shouldPause = isFirstStep || pauseOnNextStep;
|
||||
|
||||
if (!shouldPause)
|
||||
{
|
||||
Trace.Info("Step starting without debugger pause");
|
||||
@@ -860,10 +1210,36 @@ 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");
|
||||
@@ -935,7 +1311,7 @@ namespace GitHub.Runner.Worker.Dap
|
||||
SupportsTerminateRequest = false,
|
||||
SupportTerminateDebuggee = false,
|
||||
SupportsDelayedStackTraceLoading = false,
|
||||
SupportsLoadedSourcesRequest = false,
|
||||
SupportsLoadedSourcesRequest = true,
|
||||
SupportsProgressReporting = false,
|
||||
SupportsRunInTerminalRequest = false,
|
||||
SupportsCancelRequest = false,
|
||||
@@ -1009,72 +1385,171 @@ namespace GitHub.Runner.Worker.Dap
|
||||
return CreateResponse(request, true, body: body);
|
||||
}
|
||||
|
||||
private Response HandleStackTrace(Request request)
|
||||
internal Response HandleStackTrace(Request request)
|
||||
{
|
||||
IStep currentStep;
|
||||
int currentStepIndex;
|
||||
CompletedStepInfo[] completedSteps;
|
||||
JobExecutionView view;
|
||||
bool jobCompleted;
|
||||
lock (_stateLock)
|
||||
{
|
||||
currentStep = _currentStep;
|
||||
currentStepIndex = _currentStepIndex;
|
||||
completedSteps = _completedSteps.ToArray();
|
||||
view = _executionView;
|
||||
jobCompleted = _jobCompleted;
|
||||
}
|
||||
|
||||
var frames = new List<StackFrame>();
|
||||
|
||||
// Add current step as the top frame
|
||||
if (currentStep != null)
|
||||
if (view != null)
|
||||
{
|
||||
var resultIndicator = currentStep.ExecutionContext?.Result != null
|
||||
? $" [{currentStep.ExecutionContext.Result}]"
|
||||
: " [running]";
|
||||
var source = BuildExecutionViewSource(view.JobId);
|
||||
|
||||
frames.Add(new StackFrame
|
||||
if (jobCompleted)
|
||||
{
|
||||
Id = _currentFrameId,
|
||||
Name = MaskUserVisibleText($"{currentStep.DisplayName ?? "Current Step"}{resultIndicator}"),
|
||||
Line = currentStepIndex + 1,
|
||||
Column = 1,
|
||||
PresentationHint = "normal"
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
frames.Add(new StackFrame
|
||||
// Surface the synthetic Complete job step so the client
|
||||
// highlights the cleanup line at end-of-job pause.
|
||||
frames.Add(new StackFrame
|
||||
{
|
||||
Id = _currentFrameId,
|
||||
Name = MaskUserVisibleText("Complete job"),
|
||||
Line = view.CompleteJobLine,
|
||||
Column = 1,
|
||||
Source = source,
|
||||
PresentationHint = "normal",
|
||||
});
|
||||
}
|
||||
else if (currentStep != null)
|
||||
{
|
||||
Id = _currentFrameId,
|
||||
Name = "(no step executing)",
|
||||
Line = 0,
|
||||
Column = 1,
|
||||
PresentationHint = "subtle"
|
||||
});
|
||||
}
|
||||
// Frame 0: the currently-executing step (only when one is set).
|
||||
var stepLine = view.TryGetLineForStep(currentStep) ?? 1;
|
||||
frames.Add(new StackFrame
|
||||
{
|
||||
Id = _currentFrameId,
|
||||
Name = MaskUserVisibleText(currentStep.DisplayName ?? "step"),
|
||||
Line = stepLine,
|
||||
Column = 1,
|
||||
Source = source,
|
||||
PresentationHint = "normal",
|
||||
});
|
||||
}
|
||||
|
||||
// Add completed steps as additional frames (most recent first)
|
||||
for (int i = completedSteps.Length - 1; i >= 0; i--)
|
||||
{
|
||||
var completedStep = completedSteps[i];
|
||||
var resultStr = completedStep.Result.HasValue ? $" [{completedStep.Result}]" : "";
|
||||
// Frame 1: the job (anchors the stack; line 1 = the synthesized header).
|
||||
frames.Add(new StackFrame
|
||||
{
|
||||
Id = completedStep.FrameId,
|
||||
Name = MaskUserVisibleText($"{completedStep.DisplayName}{resultStr}"),
|
||||
Id = _jobFrameId,
|
||||
Name = MaskUserVisibleText($"job: {view.JobId}"),
|
||||
Line = 1,
|
||||
Column = 1,
|
||||
PresentationHint = "subtle"
|
||||
Source = source,
|
||||
PresentationHint = "subtle",
|
||||
});
|
||||
}
|
||||
else if (currentStep != null)
|
||||
{
|
||||
// Defensive: view not yet built but a step is executing.
|
||||
// Still emit a single frame with no Source so the client doesn't choke.
|
||||
frames.Add(new StackFrame
|
||||
{
|
||||
Id = _currentFrameId,
|
||||
Name = MaskUserVisibleText(currentStep.DisplayName ?? "step"),
|
||||
Line = 1,
|
||||
Column = 1,
|
||||
PresentationHint = "normal",
|
||||
});
|
||||
}
|
||||
|
||||
var body = new StackTraceResponseBody
|
||||
{
|
||||
StackFrames = frames,
|
||||
TotalFrames = frames.Count
|
||||
TotalFrames = frames.Count,
|
||||
};
|
||||
|
||||
return CreateResponse(request, true, body: body);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the synthesized job execution view <see cref="Source"/> descriptor.
|
||||
/// All frames in a session share one Source; the client retrieves its
|
||||
/// content via the DAP <c>source</c> request keyed by <see cref="_executionViewSourceReference"/>.
|
||||
/// </summary>
|
||||
private Source BuildExecutionViewSource(string jobId)
|
||||
{
|
||||
return new Source
|
||||
{
|
||||
Name = MaskUserVisibleText("execution.yml"),
|
||||
Path = MaskUserVisibleText($"{jobId}/execution.yml"),
|
||||
SourceReference = _executionViewSourceReference,
|
||||
PresentationHint = "normal",
|
||||
};
|
||||
}
|
||||
|
||||
internal Response HandleSource(Request request)
|
||||
{
|
||||
SourceArguments args;
|
||||
try
|
||||
{
|
||||
args = request.Arguments?.ToObject<SourceArguments>();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Warning($"Failed to parse source arguments: {ex.GetType().Name}");
|
||||
return CreateResponse(request, false, "Invalid source arguments.", body: null);
|
||||
}
|
||||
|
||||
if (args == null)
|
||||
{
|
||||
return CreateResponse(request, false, "Missing source arguments.", body: null);
|
||||
}
|
||||
|
||||
JobExecutionView view;
|
||||
lock (_stateLock)
|
||||
{
|
||||
view = _executionView;
|
||||
}
|
||||
|
||||
if (view == null)
|
||||
{
|
||||
return CreateResponse(request, false, "Execution view not yet available.", body: null);
|
||||
}
|
||||
|
||||
if (args.SourceReference != _executionViewSourceReference)
|
||||
{
|
||||
return CreateResponse(request, false, $"Unknown source reference: {args.SourceReference}.", body: null);
|
||||
}
|
||||
|
||||
var body = new SourceResponseBody
|
||||
{
|
||||
Content = MaskUserVisibleText(view.Yaml),
|
||||
// MimeType intentionally unset: VS Code's debug content provider
|
||||
// short-circuits language detection on the response's mimeType
|
||||
// (exact-match against its registered language mimetypes) and
|
||||
// falls back to plaintext on unknown values. The IANA YAML type
|
||||
// "application/yaml" is not in VS Code's table (it only knows
|
||||
// the legacy "text/x-yaml" synthesized for the built-in YAML
|
||||
// language contribution). By omitting mimeType, clients fall
|
||||
// through to path-extension detection — `.yml` in Source.Path
|
||||
// is the universal mechanism every DAP client honors
|
||||
// consistently (VS Code, nvim-dap, JetBrains).
|
||||
};
|
||||
|
||||
return CreateResponse(request, true, body: body);
|
||||
}
|
||||
|
||||
internal Response HandleLoadedSources(Request request)
|
||||
{
|
||||
JobExecutionView view;
|
||||
lock (_stateLock)
|
||||
{
|
||||
view = _executionView;
|
||||
}
|
||||
|
||||
var body = new LoadedSourcesResponseBody();
|
||||
if (view != null)
|
||||
{
|
||||
body.Sources.Add(BuildExecutionViewSource(view.JobId));
|
||||
}
|
||||
return CreateResponse(request, true, body: body);
|
||||
}
|
||||
|
||||
private Response HandleScopes(Request request)
|
||||
{
|
||||
var args = request.Arguments?.ToObject<ScopesArguments>();
|
||||
@@ -1195,7 +1670,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
|
||||
@@ -1290,11 +1770,40 @@ namespace GitHub.Runner.Worker.Dap
|
||||
return CreateResponse(request, true, body: null);
|
||||
}
|
||||
|
||||
private Response HandleSetBreakpoints(Request request)
|
||||
internal Response HandleSetBreakpoints(Request request)
|
||||
{
|
||||
// MVP: acknowledge but don't process breakpoints
|
||||
// All steps pause automatically via _pauseOnNextStep
|
||||
return CreateResponse(request, true, body: new { breakpoints = Array.Empty<object>() });
|
||||
SetBreakpointsArguments args = null;
|
||||
try
|
||||
{
|
||||
args = request.Arguments?.ToObject<SetBreakpointsArguments>();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Warning($"Failed to parse setBreakpoints arguments: {ex.GetType().Name}");
|
||||
}
|
||||
|
||||
JobExecutionView view;
|
||||
lock (_stateLock)
|
||||
{
|
||||
view = _executionView;
|
||||
}
|
||||
|
||||
var body = new SetBreakpointsResponseBody();
|
||||
if (args?.Breakpoints != null && view != null)
|
||||
{
|
||||
var source = BuildExecutionViewSource(view.JobId);
|
||||
foreach (var requested in args.Breakpoints)
|
||||
{
|
||||
body.Breakpoints.Add(new Breakpoint
|
||||
{
|
||||
Verified = false,
|
||||
Line = requested.Line,
|
||||
Source = source,
|
||||
Message = "Breakpoint support is coming in a future runner release. The debugger currently pauses at every step boundary; use 'continue' to advance.",
|
||||
});
|
||||
}
|
||||
}
|
||||
return CreateResponse(request, true, body: body);
|
||||
}
|
||||
|
||||
private Response HandleSetExceptionBreakpoints(Request request)
|
||||
@@ -1359,8 +1868,7 @@ namespace GitHub.Runner.Worker.Dap
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the execution context for a given stack frame ID.
|
||||
/// Frame 1 = current step; frames 1000+ = completed steps (no
|
||||
/// context available - those steps have already finished).
|
||||
/// Frame 1 = current step; frame 2 = job-level (subtle anchor frame).
|
||||
/// Falls back to the job-level context when no step is active.
|
||||
/// </summary>
|
||||
private IExecutionContext GetExecutionContextForFrame(int frameId)
|
||||
@@ -1370,7 +1878,7 @@ namespace GitHub.Runner.Worker.Dap
|
||||
return GetCurrentExecutionContext();
|
||||
}
|
||||
|
||||
// Completed-step frames don't carry a live execution context.
|
||||
// Job/anchor frame — no step-level context.
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1407,6 +1915,67 @@ namespace GitHub.Runner.Worker.Dap
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emits a console output banner telling the user whether REPL
|
||||
/// commands will execute on the host or inside the job container.
|
||||
/// </summary>
|
||||
private void SendExecutionContextBanner()
|
||||
{
|
||||
if (!_isClientConnected)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
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)))
|
||||
{
|
||||
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");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a loadedSource event with the current execution view's source.
|
||||
/// No-op if the view has not been built yet.
|
||||
/// </summary>
|
||||
private void SendLoadedSourceEvent(string reason)
|
||||
{
|
||||
JobExecutionView view;
|
||||
lock (_stateLock)
|
||||
{
|
||||
view = _executionView;
|
||||
}
|
||||
if (view == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
SendEvent(new Event
|
||||
{
|
||||
EventType = "loadedSource",
|
||||
Body = new LoadedSourceEventBody
|
||||
{
|
||||
Reason = reason,
|
||||
Source = BuildExecutionViewSource(view.JobId),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private string MaskUserVisibleText(string value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value))
|
||||
|
||||
@@ -537,6 +537,132 @@ namespace GitHub.Runner.Worker.Dap
|
||||
|
||||
#endregion
|
||||
|
||||
#region Source Request/Response
|
||||
|
||||
/// <summary>
|
||||
/// Arguments for 'source' request.
|
||||
/// </summary>
|
||||
public class SourceArguments
|
||||
{
|
||||
/// <summary>
|
||||
/// Source descriptor (optional, redundant with sourceReference).
|
||||
/// </summary>
|
||||
[JsonProperty("source", NullValueHandling = NullValueHandling.Ignore)]
|
||||
public Source Source { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The reference to the source. Required by DAP spec.
|
||||
/// </summary>
|
||||
[JsonProperty("sourceReference")]
|
||||
public int SourceReference { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response body for 'source' request.
|
||||
/// </summary>
|
||||
public class SourceResponseBody
|
||||
{
|
||||
/// <summary>
|
||||
/// Content of the source as a string.
|
||||
/// </summary>
|
||||
[JsonProperty("content")]
|
||||
public string Content { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional content type / mime type of the source.
|
||||
/// </summary>
|
||||
[JsonProperty("mimeType", NullValueHandling = NullValueHandling.Ignore)]
|
||||
public string MimeType { get; set; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region LoadedSources Request/Response
|
||||
|
||||
/// <summary>
|
||||
/// Response body for 'loadedSources' request.
|
||||
/// </summary>
|
||||
public class LoadedSourcesResponseBody
|
||||
{
|
||||
[JsonProperty("sources")]
|
||||
public List<Source> Sources { get; set; } = new List<Source>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Body for 'loadedSource' event.
|
||||
/// </summary>
|
||||
public class LoadedSourceEventBody
|
||||
{
|
||||
/// <summary>
|
||||
/// "new" | "changed" | "removed"
|
||||
/// </summary>
|
||||
[JsonProperty("reason")]
|
||||
public string Reason { get; set; }
|
||||
|
||||
[JsonProperty("source")]
|
||||
public Source Source { get; set; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region SetBreakpoints Request/Response
|
||||
|
||||
/// <summary>
|
||||
/// Arguments for 'setBreakpoints' request.
|
||||
/// </summary>
|
||||
public class SetBreakpointsArguments
|
||||
{
|
||||
[JsonProperty("source", NullValueHandling = NullValueHandling.Ignore)]
|
||||
public Source Source { get; set; }
|
||||
|
||||
[JsonProperty("breakpoints")]
|
||||
public List<SourceBreakpoint> Breakpoints { get; set; } = new List<SourceBreakpoint>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Properties of a breakpoint passed to the setBreakpoints request.
|
||||
/// </summary>
|
||||
public class SourceBreakpoint
|
||||
{
|
||||
[JsonProperty("line")]
|
||||
public int Line { get; set; }
|
||||
|
||||
[JsonProperty("condition", NullValueHandling = NullValueHandling.Ignore)]
|
||||
public string Condition { get; set; }
|
||||
|
||||
[JsonProperty("logMessage", NullValueHandling = NullValueHandling.Ignore)]
|
||||
public string LogMessage { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response body for 'setBreakpoints' request.
|
||||
/// </summary>
|
||||
public class SetBreakpointsResponseBody
|
||||
{
|
||||
[JsonProperty("breakpoints")]
|
||||
public List<Breakpoint> Breakpoints { get; set; } = new List<Breakpoint>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Information about a breakpoint created in setBreakpoints.
|
||||
/// </summary>
|
||||
public class Breakpoint
|
||||
{
|
||||
[JsonProperty("verified")]
|
||||
public bool Verified { get; set; }
|
||||
|
||||
[JsonProperty("line", NullValueHandling = NullValueHandling.Ignore)]
|
||||
public int? Line { get; set; }
|
||||
|
||||
[JsonProperty("source", NullValueHandling = NullValueHandling.Ignore)]
|
||||
public Source Source { get; set; }
|
||||
|
||||
[JsonProperty("message", NullValueHandling = NullValueHandling.Ignore)]
|
||||
public string Message { get; set; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Scopes Request/Response
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Threading.Tasks;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using GitHub.Runner.Common;
|
||||
|
||||
namespace GitHub.Runner.Worker.Dap
|
||||
@@ -21,6 +22,23 @@ namespace GitHub.Runner.Worker.Dap
|
||||
Task WaitUntilReadyAsync();
|
||||
Task OnStepStartingAsync(IStep step);
|
||||
void OnStepCompleted(IStep step);
|
||||
|
||||
/// <summary>
|
||||
/// Called after JobExtension.InitializeJob has returned and the initial
|
||||
/// step queue + post-step stack have been populated. The debugger uses
|
||||
/// these snapshots to build the synthesized job execution view served
|
||||
/// via the DAP source request.
|
||||
/// </summary>
|
||||
Task OnJobStepsInitializedAsync(IEnumerable<IStep> mainQueue, IEnumerable<IStep> initialPostStack);
|
||||
|
||||
/// <summary>
|
||||
/// Called from ExecutionContext.RegisterPostJobStep after a post-step
|
||||
/// is pushed onto the post-job stack. The debugger appends the step
|
||||
/// to the running execution view so the rendered YAML reflects the
|
||||
/// newly-known post-step.
|
||||
/// </summary>
|
||||
void OnPostStepRegistered(IStep step);
|
||||
|
||||
Task OnJobCompletedAsync();
|
||||
Task StopAsync();
|
||||
}
|
||||
|
||||
293
src/Runner.Worker/Dap/JobExecutionView.cs
Normal file
293
src/Runner.Worker/Dap/JobExecutionView.cs
Normal file
@@ -0,0 +1,293 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using GitHub.Runner.Sdk;
|
||||
|
||||
namespace GitHub.Runner.Worker.Dap
|
||||
{
|
||||
/// <summary>
|
||||
/// Stateful, append-only container that wraps <see cref="JobExecutionViewRenderer"/>
|
||||
/// for runtime use. Maintains a mutable list of entries, caches the rendered YAML,
|
||||
/// 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.
|
||||
///
|
||||
/// 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
|
||||
{
|
||||
private readonly object _lock = new();
|
||||
private readonly string _jobId;
|
||||
private readonly List<JobExecutionViewEntry> _entries = new();
|
||||
private readonly List<IStep> _stepIdentities = new();
|
||||
private readonly Dictionary<IStep, int> _lineByStep =
|
||||
new(ReferenceEqualityComparer.Instance);
|
||||
// Map matchKey -> entry index for placeholders awaiting a future
|
||||
// TryClaim. Removed when claimed.
|
||||
private readonly Dictionary<string, int> _unclaimedByKey =
|
||||
new(StringComparer.Ordinal);
|
||||
private string _yaml;
|
||||
private IReadOnlyList<int> _entryStartLines = Array.Empty<int>();
|
||||
private int _completeJobLine;
|
||||
|
||||
public JobExecutionView(string jobId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(jobId))
|
||||
{
|
||||
throw new ArgumentException("jobId must not be null or whitespace.", nameof(jobId));
|
||||
}
|
||||
|
||||
_jobId = jobId;
|
||||
Render();
|
||||
}
|
||||
|
||||
public string JobId
|
||||
{
|
||||
get { return _jobId; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Currently rendered YAML. Always reflects all entries appended so far,
|
||||
/// plus the synthetic Setup header and Cleanup footer emitted by the renderer.
|
||||
/// </summary>
|
||||
public string Yaml
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _yaml;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 1-based line where the synthetic <c>- step: Complete job</c> entry
|
||||
/// appears in <see cref="Yaml"/>. Always non-zero — Cleanup is always emitted.
|
||||
/// </summary>
|
||||
public int CompleteJobLine
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _completeJobLine;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Number of entries (excludes synthetic Setup/Cleanup boundaries).</summary>
|
||||
public int EntryCount
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _entries.Count;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 1-based line where entry <paramref name="entryIndex"/>'s <c>- step:</c> key
|
||||
/// currently appears in <see cref="Yaml"/>.
|
||||
/// </summary>
|
||||
public int GetLine(int entryIndex)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (entryIndex < 0 || entryIndex >= _entries.Count)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(entryIndex));
|
||||
}
|
||||
|
||||
return _entryStartLines[entryIndex];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 1-based line for the entry whose <see cref="IStep"/> reference identity
|
||||
/// matches <paramref name="step"/>. Returns null if <paramref name="step"/>
|
||||
/// is null or has not been registered.
|
||||
/// </summary>
|
||||
public int? TryGetLineForStep(IStep step)
|
||||
{
|
||||
if (step == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
if (_lineByStep.TryGetValue(step, out var line))
|
||||
{
|
||||
return line;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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)
|
||||
{
|
||||
ArgUtil.NotNull(entry, nameof(entry));
|
||||
if (stepIdentity != null && matchKey != null)
|
||||
{
|
||||
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)
|
||||
{
|
||||
if (stepIdentity != null && _lineByStep.ContainsKey(stepIdentity))
|
||||
{
|
||||
throw new InvalidOperationException("step already registered in execution view");
|
||||
}
|
||||
if (matchKey != null && _unclaimedByKey.ContainsKey(matchKey))
|
||||
{
|
||||
throw new InvalidOperationException($"matchKey already registered: {matchKey}");
|
||||
}
|
||||
|
||||
_entries.Add(entry);
|
||||
_stepIdentities.Add(stepIdentity);
|
||||
Render();
|
||||
|
||||
int index = _entries.Count - 1;
|
||||
if (matchKey != null)
|
||||
{
|
||||
_unclaimedByKey[matchKey] = index;
|
||||
}
|
||||
return _entryStartLines[index];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bind a previously-appended placeholder entry (registered via
|
||||
/// <see cref="Append(JobExecutionViewEntry, IStep, string)"/> with
|
||||
/// a non-null <c>matchKey</c>) to a real <see cref="IStep"/>.
|
||||
/// Returns the 1-based line of the now-claimed entry on success.
|
||||
/// Returns null when no unclaimed placeholder exists for
|
||||
/// <paramref name="matchKey"/>, OR when <paramref name="stepIdentity"/>
|
||||
/// is already registered for a different entry (defensive).
|
||||
/// Does not re-render: claim only updates the IStep -> line index.
|
||||
/// </summary>
|
||||
public int? TryClaim(string matchKey, IStep stepIdentity)
|
||||
{
|
||||
if (matchKey == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(matchKey));
|
||||
}
|
||||
if (stepIdentity == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(stepIdentity));
|
||||
}
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
if (!_unclaimedByKey.TryGetValue(matchKey, out int index))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
if (_lineByStep.ContainsKey(stepIdentity))
|
||||
{
|
||||
// Bail rather than double-register the step.
|
||||
return null;
|
||||
}
|
||||
|
||||
_unclaimedByKey.Remove(matchKey);
|
||||
_stepIdentities[index] = stepIdentity;
|
||||
_lineByStep[stepIdentity] = _entryStartLines[index];
|
||||
return _entryStartLines[index];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bulk-append for the initial population. Equivalent to calling
|
||||
/// <see cref="Append"/> once per pair, but renders only once at the end.
|
||||
/// State is left unchanged if any input is invalid.
|
||||
/// </summary>
|
||||
public void AppendRange(IEnumerable<(JobExecutionViewEntry entry, IStep stepIdentity)> items)
|
||||
{
|
||||
ArgUtil.NotNull(items, nameof(items));
|
||||
|
||||
// Materialize first so we don't enumerate twice.
|
||||
var materialized = new List<(JobExecutionViewEntry entry, IStep stepIdentity)>(items);
|
||||
for (int i = 0; i < materialized.Count; i++)
|
||||
{
|
||||
if (materialized[i].entry == null)
|
||||
{
|
||||
throw new ArgumentException($"items[{i}].entry is null.", nameof(items));
|
||||
}
|
||||
}
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
// Validate no duplicates within the input or with existing identities,
|
||||
// before mutating state.
|
||||
var seen = new HashSet<IStep>(ReferenceEqualityComparer.Instance);
|
||||
foreach (var (_, stepIdentity) in materialized)
|
||||
{
|
||||
if (stepIdentity == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (_lineByStep.ContainsKey(stepIdentity) || !seen.Add(stepIdentity))
|
||||
{
|
||||
throw new InvalidOperationException("step already registered in execution view");
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var (entry, stepIdentity) in materialized)
|
||||
{
|
||||
_entries.Add(entry);
|
||||
_stepIdentities.Add(stepIdentity);
|
||||
}
|
||||
Render();
|
||||
}
|
||||
}
|
||||
|
||||
// Caller MUST hold _lock (constructor's call is safe — no concurrent access yet).
|
||||
private void Render()
|
||||
{
|
||||
var result = JobExecutionViewRenderer.Render(_jobId, _entries.AsReadOnly());
|
||||
_yaml = result.Yaml;
|
||||
_entryStartLines = result.EntryStartLines;
|
||||
_completeJobLine = result.CompleteJobLine;
|
||||
|
||||
_lineByStep.Clear();
|
||||
for (int i = 0; i < _stepIdentities.Count; i++)
|
||||
{
|
||||
var step = _stepIdentities[i];
|
||||
if (step != null)
|
||||
{
|
||||
_lineByStep[step] = _entryStartLines[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
345
src/Runner.Worker/Dap/JobExecutionViewRenderer.cs
Normal file
345
src/Runner.Worker/Dap/JobExecutionViewRenderer.cs
Normal file
@@ -0,0 +1,345 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using GitHub.Runner.Sdk;
|
||||
|
||||
namespace GitHub.Runner.Worker.Dap
|
||||
{
|
||||
/// <summary>
|
||||
/// Phase a step occupies in the runner's flat execution sequence.
|
||||
/// Setup and Cleanup are NOT modeled here — they are synthetic
|
||||
/// boundaries hard-coded by <see cref="JobExecutionViewRenderer"/>
|
||||
/// and cannot be constructed by callers.
|
||||
/// </summary>
|
||||
internal enum JobExecutionPhase
|
||||
{
|
||||
Pre,
|
||||
Main,
|
||||
Post,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One step in the rendered execution view. Pure data; no link to
|
||||
/// any worker type. Phase 2 will translate runner step objects
|
||||
/// into instances of this record.
|
||||
/// </summary>
|
||||
internal sealed class JobExecutionViewEntry
|
||||
{
|
||||
public JobExecutionViewEntry(
|
||||
JobExecutionPhase phase,
|
||||
string displayName,
|
||||
string uses = null,
|
||||
string run = null,
|
||||
string sourcePath = null,
|
||||
int sourceLine = 0,
|
||||
string id = null,
|
||||
string @if = null,
|
||||
string continueOnError = null,
|
||||
string timeoutMinutes = null,
|
||||
string envYaml = null,
|
||||
string withYaml = null,
|
||||
string shell = null,
|
||||
string workingDirectory = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(displayName))
|
||||
{
|
||||
throw new ArgumentException("displayName must not be null or whitespace.", nameof(displayName));
|
||||
}
|
||||
if (sourcePath != null && sourceLine < 1)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"sourceLine must be >= 1 when sourcePath is provided.",
|
||||
nameof(sourceLine));
|
||||
}
|
||||
|
||||
Phase = phase;
|
||||
DisplayName = displayName;
|
||||
Uses = uses;
|
||||
Run = run;
|
||||
SourcePath = sourcePath;
|
||||
SourceLine = sourceLine;
|
||||
Id = id;
|
||||
If = @if;
|
||||
ContinueOnError = continueOnError;
|
||||
TimeoutMinutes = timeoutMinutes;
|
||||
EnvYaml = envYaml;
|
||||
WithYaml = withYaml;
|
||||
Shell = shell;
|
||||
WorkingDirectory = workingDirectory;
|
||||
}
|
||||
|
||||
public JobExecutionPhase Phase { get; }
|
||||
public string DisplayName { get; }
|
||||
public string Uses { get; }
|
||||
public string Run { get; }
|
||||
public string SourcePath { get; }
|
||||
public int SourceLine { get; }
|
||||
public string Id { get; }
|
||||
public string If { get; }
|
||||
public string ContinueOnError { get; }
|
||||
public string TimeoutMinutes { get; }
|
||||
// Pre-serialized YAML fragment, already indented for embedding
|
||||
// under the entry's `env:` key (6-space child indent).
|
||||
public string EnvYaml { get; }
|
||||
public string WithYaml { get; }
|
||||
public string Shell { get; }
|
||||
public string WorkingDirectory { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Output of <see cref="JobExecutionViewRenderer.Render"/>: the YAML
|
||||
/// document plus a parallel array of 1-based line numbers, one per
|
||||
/// input entry, where each entry's <c>- step:</c> key appears.
|
||||
/// Synthetic Setup/Cleanup boundaries are not tracked here.
|
||||
/// </summary>
|
||||
internal readonly struct RenderResult
|
||||
{
|
||||
public RenderResult(string yaml, IReadOnlyList<int> entryStartLines, int completeJobLine)
|
||||
{
|
||||
Yaml = yaml;
|
||||
EntryStartLines = entryStartLines;
|
||||
CompleteJobLine = completeJobLine;
|
||||
}
|
||||
|
||||
public string Yaml { get; }
|
||||
public IReadOnlyList<int> EntryStartLines { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 1-based line where the synthetic <c>- step: Complete job</c> entry
|
||||
/// appears in <see cref="Yaml"/>. Always non-zero — Cleanup is always emitted.
|
||||
/// </summary>
|
||||
public int CompleteJobLine { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders a job's execution-view YAML. Pure function; no I/O,
|
||||
/// no logging, no static state. Output format and Setup/Cleanup
|
||||
/// boundaries are fixed; callers cannot influence them.
|
||||
///
|
||||
/// Output is structured as phase-keyed top-level sections:
|
||||
/// <c>setup:</c>, <c>pre:</c>, <c>main:</c>, <c>post:</c>, <c>cleanup:</c>.
|
||||
/// <c>setup:</c> and <c>cleanup:</c> always render; <c>pre:</c>,
|
||||
/// <c>main:</c>, <c>post:</c> only render when they contain at least
|
||||
/// one entry.
|
||||
/// </summary>
|
||||
internal static class JobExecutionViewRenderer
|
||||
{
|
||||
public static RenderResult Render(string jobId, IReadOnlyList<JobExecutionViewEntry> entries)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(jobId))
|
||||
{
|
||||
throw new ArgumentException("jobId must not be null or whitespace.", nameof(jobId));
|
||||
}
|
||||
ArgUtil.NotNull(entries, nameof(entries));
|
||||
|
||||
// Pre-validate non-null entries before any output, so partial
|
||||
// state is never observed by callers.
|
||||
for (int i = 0; i < entries.Count; i++)
|
||||
{
|
||||
if (entries[i] == null)
|
||||
{
|
||||
throw new ArgumentException($"entries[{i}] is null.", nameof(entries));
|
||||
}
|
||||
}
|
||||
|
||||
var sb = new StringBuilder();
|
||||
var startLines = new int[entries.Count];
|
||||
int newlinesEmitted = 0;
|
||||
|
||||
// Header (3 lines).
|
||||
sb.Append("# Job: ").Append(YamlScalarFormatter.Format(jobId)).Append('\n');
|
||||
sb.Append("# Runner execution plan — read-only.\n");
|
||||
sb.Append('\n');
|
||||
newlinesEmitted += 3;
|
||||
|
||||
// setup: section — always present.
|
||||
sb.Append("setup:\n");
|
||||
sb.Append(" - step: Setup job\n");
|
||||
newlinesEmitted += 2;
|
||||
|
||||
// Render phase sections in fixed order. Each emits a leading
|
||||
// blank line separator before its header.
|
||||
EmitPhaseSection(sb, "pre", JobExecutionPhase.Pre, entries, startLines, ref newlinesEmitted);
|
||||
EmitPhaseSection(sb, "main", JobExecutionPhase.Main, entries, startLines, ref newlinesEmitted);
|
||||
EmitPhaseSection(sb, "post", JobExecutionPhase.Post, entries, startLines, ref newlinesEmitted);
|
||||
|
||||
// cleanup: section — always present, preceded by a blank line.
|
||||
sb.Append('\n');
|
||||
sb.Append("cleanup:\n");
|
||||
newlinesEmitted += 2;
|
||||
int completeJobLine = newlinesEmitted + 1;
|
||||
sb.Append(" - step: Complete job\n");
|
||||
|
||||
return new RenderResult(sb.ToString(), Array.AsReadOnly(startLines), completeJobLine);
|
||||
}
|
||||
|
||||
private static void EmitPhaseSection(
|
||||
StringBuilder sb,
|
||||
string sectionName,
|
||||
JobExecutionPhase phase,
|
||||
IReadOnlyList<JobExecutionViewEntry> entries,
|
||||
int[] startLines,
|
||||
ref int newlinesEmitted)
|
||||
{
|
||||
// Skip the section entirely if no entries belong to this phase.
|
||||
bool any = false;
|
||||
for (int i = 0; i < entries.Count; i++)
|
||||
{
|
||||
if (entries[i].Phase == phase) { any = true; break; }
|
||||
}
|
||||
if (!any)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Blank line separator + section header.
|
||||
sb.Append('\n');
|
||||
sb.Append(sectionName).Append(":\n");
|
||||
newlinesEmitted += 2;
|
||||
|
||||
for (int i = 0; i < entries.Count; i++)
|
||||
{
|
||||
var entry = entries[i];
|
||||
if (entry.Phase != phase)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// 1-based line of the `- step:` key for this entry.
|
||||
startLines[i] = newlinesEmitted + 1;
|
||||
|
||||
sb.Append(" - step: ").Append(YamlScalarFormatter.Format(entry.DisplayName));
|
||||
sb.Append('\n');
|
||||
newlinesEmitted++;
|
||||
|
||||
switch (phase)
|
||||
{
|
||||
case JobExecutionPhase.Pre:
|
||||
case JobExecutionPhase.Post:
|
||||
if (!string.IsNullOrEmpty(entry.Uses))
|
||||
{
|
||||
sb.Append(" action: ").Append(YamlScalarFormatter.Format(entry.Uses)).Append('\n');
|
||||
newlinesEmitted++;
|
||||
}
|
||||
// No source: annotation for pre/post.
|
||||
break;
|
||||
|
||||
case JobExecutionPhase.Main:
|
||||
if (!string.IsNullOrEmpty(entry.Id))
|
||||
{
|
||||
sb.Append(" id: ").Append(YamlScalarFormatter.Format(entry.Id)).Append('\n');
|
||||
newlinesEmitted++;
|
||||
}
|
||||
if (!string.IsNullOrEmpty(entry.Uses))
|
||||
{
|
||||
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(YamlScalarFormatter.Format(entry.Run)).Append('\n');
|
||||
newlinesEmitted++;
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append(" run: |\n");
|
||||
newlinesEmitted++;
|
||||
newlinesEmitted += AppendIndentedBlock(sb, entry.Run, " ");
|
||||
}
|
||||
}
|
||||
if (!string.IsNullOrEmpty(entry.If))
|
||||
{
|
||||
sb.Append(" if: ").Append(YamlScalarFormatter.Format(entry.If)).Append('\n');
|
||||
newlinesEmitted++;
|
||||
}
|
||||
if (!string.IsNullOrEmpty(entry.ContinueOnError))
|
||||
{
|
||||
sb.Append(" continue-on-error: ").Append(entry.ContinueOnError).Append('\n');
|
||||
newlinesEmitted++;
|
||||
}
|
||||
if (!string.IsNullOrEmpty(entry.TimeoutMinutes))
|
||||
{
|
||||
sb.Append(" timeout-minutes: ").Append(entry.TimeoutMinutes).Append('\n');
|
||||
newlinesEmitted++;
|
||||
}
|
||||
if (!string.IsNullOrEmpty(entry.EnvYaml))
|
||||
{
|
||||
sb.Append(" env:\n");
|
||||
newlinesEmitted++;
|
||||
sb.Append(entry.EnvYaml).Append('\n');
|
||||
newlinesEmitted += CountChar(entry.EnvYaml, '\n') + 1;
|
||||
}
|
||||
if (!string.IsNullOrEmpty(entry.WithYaml))
|
||||
{
|
||||
sb.Append(" with:\n");
|
||||
newlinesEmitted++;
|
||||
sb.Append(entry.WithYaml).Append('\n');
|
||||
newlinesEmitted += CountChar(entry.WithYaml, '\n') + 1;
|
||||
}
|
||||
if (!string.IsNullOrEmpty(entry.Shell))
|
||||
{
|
||||
sb.Append(" shell: ").Append(YamlScalarFormatter.Format(entry.Shell)).Append('\n');
|
||||
newlinesEmitted++;
|
||||
}
|
||||
if (!string.IsNullOrEmpty(entry.WorkingDirectory))
|
||||
{
|
||||
sb.Append(" working-directory: ").Append(YamlScalarFormatter.Format(entry.WorkingDirectory)).Append('\n');
|
||||
newlinesEmitted++;
|
||||
}
|
||||
if (entry.SourcePath != null)
|
||||
{
|
||||
sb.Append(" source: ")
|
||||
.Append(entry.SourcePath)
|
||||
.Append(':')
|
||||
.Append(entry.SourceLine.ToString(CultureInfo.InvariantCulture))
|
||||
.Append('\n');
|
||||
newlinesEmitted++;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static int AppendIndentedBlock(StringBuilder sb, string text, string indent)
|
||||
{
|
||||
int newlines = 0;
|
||||
int i = 0;
|
||||
while (i < text.Length)
|
||||
{
|
||||
int end = text.IndexOf('\n', i);
|
||||
int lineEnd = end < 0 ? text.Length : end;
|
||||
int trimEnd = lineEnd;
|
||||
if (trimEnd > i && text[trimEnd - 1] == '\r')
|
||||
{
|
||||
trimEnd--;
|
||||
}
|
||||
if (trimEnd > i)
|
||||
{
|
||||
sb.Append(indent);
|
||||
sb.Append(text, i, trimEnd - i);
|
||||
}
|
||||
sb.Append('\n');
|
||||
newlines++;
|
||||
if (end < 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
i = end + 1;
|
||||
}
|
||||
return newlines;
|
||||
}
|
||||
|
||||
private static int CountChar(string s, char c)
|
||||
{
|
||||
int n = 0;
|
||||
for (int i = 0; i < s.Length; i++)
|
||||
{
|
||||
if (s[i] == c) n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
}
|
||||
}
|
||||
240
src/Runner.Worker/Dap/StepEntryTranslator.cs
Normal file
240
src/Runner.Worker/Dap/StepEntryTranslator.cs
Normal file
@@ -0,0 +1,240 @@
|
||||
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. The runner stores these under
|
||||
// the keys defined in PipelineConstants.ScriptStepInputs, NOT
|
||||
// their kebab-case workflow-YAML spellings.
|
||||
private static readonly HashSet<string> RunStepInternalKeys = new(StringComparer.Ordinal)
|
||||
{
|
||||
PipelineConstants.ScriptStepInputs.Script,
|
||||
PipelineConstants.ScriptStepInputs.Shell,
|
||||
PipelineConstants.ScriptStepInputs.WorkingDirectory,
|
||||
};
|
||||
|
||||
/// <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, PipelineConstants.ScriptStepInputs.Script, out var scriptTok) && scriptTok != null)
|
||||
{
|
||||
run = scriptTok.ToString();
|
||||
}
|
||||
if (TryGetMapValue(inputs, PipelineConstants.ScriptStepInputs.Shell, out var shellTok) && shellTok != null)
|
||||
{
|
||||
string shellText = shellTok.ToString();
|
||||
if (!string.IsNullOrEmpty(shellText))
|
||||
{
|
||||
shell = shellText;
|
||||
}
|
||||
}
|
||||
if (TryGetMapValue(inputs, PipelineConstants.ScriptStepInputs.WorkingDirectory, 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
223
src/Runner.Worker/Dap/TemplateTokenYamlAdapter.cs
Normal file
223
src/Runner.Worker/Dap/TemplateTokenYamlAdapter.cs
Normal file
@@ -0,0 +1,223 @@
|
||||
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);
|
||||
// Force LF line breaks; YamlDotNet's Emitter calls WriteLine,
|
||||
// which would otherwise produce CRLF on Windows and corrupt
|
||||
// both the document-end stripping below and the per-line
|
||||
// indentation pass that follows.
|
||||
sw.NewLine = "\n";
|
||||
var emitter = new Emitter(sw);
|
||||
var adapter = new TemplateTokenYamlAdapter(emitter);
|
||||
adapter.WriteStart();
|
||||
WriteToken(adapter, token);
|
||||
adapter.WriteEnd();
|
||||
|
||||
string raw = sw.ToString();
|
||||
// Strip YAML document markers. The Emitter most commonly elides
|
||||
// these for our use (DocumentStart isImplicit=true), but emits
|
||||
// them for some scalar edge cases (e.g. empty strings) and may
|
||||
// emit them on their own line for collection roots under some
|
||||
// settings. Strip both shapes defensively so callers never see
|
||||
// a leaked marker leak into the embedded fragment.
|
||||
if (raw.StartsWith("--- ", StringComparison.Ordinal))
|
||||
{
|
||||
raw = raw.Substring(4);
|
||||
}
|
||||
else if (raw.StartsWith("---\n", 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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mirrors <see cref="TemplateWriter"/>'s recursive walk, with one
|
||||
/// behavioural change: <see cref="BasicExpressionToken"/> is emitted
|
||||
/// via <c>ToDisplayString()</c> instead of <c>ToString()</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The workflow parser tokenizes a mixed scalar like
|
||||
/// <c>${{ runner.os }}-primes</c> as a single
|
||||
/// <see cref="BasicExpressionToken"/> whose internal expression is
|
||||
/// <c>format('{0}-primes', runner.os)</c>. <c>ToString()</c> emits
|
||||
/// the normalized form verbatim; <c>ToDisplayString()</c> reverses
|
||||
/// the <c>format(...)</c> rewrite so the user sees the original
|
||||
/// authored form. Other token kinds delegate to the same writer
|
||||
/// calls <see cref="TemplateWriter"/> would make.
|
||||
/// </remarks>
|
||||
private static void WriteToken(IObjectWriter writer, TemplateToken token)
|
||||
{
|
||||
switch (token?.Type ?? TokenType.Null)
|
||||
{
|
||||
case TokenType.Null:
|
||||
writer.WriteNull();
|
||||
break;
|
||||
case TokenType.Boolean:
|
||||
writer.WriteBoolean(((BooleanToken)token).Value);
|
||||
break;
|
||||
case TokenType.Number:
|
||||
writer.WriteNumber(((NumberToken)token).Value);
|
||||
break;
|
||||
case TokenType.String:
|
||||
writer.WriteString(token.ToString());
|
||||
break;
|
||||
case TokenType.BasicExpression:
|
||||
writer.WriteString(((BasicExpressionToken)token).ToDisplayString());
|
||||
break;
|
||||
case TokenType.InsertExpression:
|
||||
writer.WriteString(token.ToString());
|
||||
break;
|
||||
case TokenType.Mapping:
|
||||
writer.WriteMappingStart();
|
||||
foreach (var pair in (MappingToken)token)
|
||||
{
|
||||
WriteToken(writer, pair.Key);
|
||||
WriteToken(writer, pair.Value);
|
||||
}
|
||||
writer.WriteMappingEnd();
|
||||
break;
|
||||
case TokenType.Sequence:
|
||||
writer.WriteSequenceStart();
|
||||
foreach (var item in (SequenceToken)token)
|
||||
{
|
||||
WriteToken(writer, item);
|
||||
}
|
||||
writer.WriteSequenceEnd();
|
||||
break;
|
||||
default:
|
||||
throw new NotSupportedException($"Unexpected token type '{token.GetType()}'.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
63
src/Runner.Worker/Dap/YamlScalarFormatter.cs
Normal file
63
src/Runner.Worker/Dap/YamlScalarFormatter.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -338,6 +338,14 @@ 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(
|
||||
@@ -970,7 +978,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;
|
||||
|
||||
@@ -12,6 +12,7 @@ using GitHub.Runner.Common.Util;
|
||||
using GitHub.Runner.Sdk;
|
||||
using GitHub.Runner.Worker.Container;
|
||||
using GitHub.Runner.Worker.Container.ContainerHooks;
|
||||
using GitHub.Services.Common;
|
||||
|
||||
namespace GitHub.Runner.Worker.Handlers
|
||||
{
|
||||
@@ -128,6 +129,15 @@ namespace GitHub.Runner.Worker.Handlers
|
||||
// file name character on Linux.
|
||||
string arguments = StepHost.ResolvePathForStepHost(ExecutionContext, StringUtil.Format(@"""{0}""", target.Replace(@"""", @"\""")));
|
||||
|
||||
// Disable maglev jit compiler in node.js 24.x.x on x64 Windows until the node.js bug is fixed.
|
||||
// https://github.com/nodejs/node/issues/62260
|
||||
if (nodeRuntimeVersion.StartsWith("node24", StringComparison.OrdinalIgnoreCase) &&
|
||||
(StringUtil.ConvertToBoolean(System.Environment.GetEnvironmentVariable("ACTIONS_RUNNER_DISABLE_NODE_MAGLEV")) || StringUtil.ConvertToBoolean(Environment.GetValueOrDefault("ACTIONS_RUNNER_DISABLE_NODE_MAGLEV"))))
|
||||
{
|
||||
Trace.Info("Disable maglev jit compiler in node.js");
|
||||
arguments = $"--no-maglev {arguments}";
|
||||
}
|
||||
|
||||
#if OS_WINDOWS
|
||||
// It appears that node.exe outputs UTF8 when not in TTY mode.
|
||||
Encoding outputEncoding = Encoding.UTF8;
|
||||
|
||||
@@ -230,6 +230,24 @@ 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)
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="10.0.3" />
|
||||
<PackageReference Include="System.ServiceProcess.ServiceController" Version="10.0.8" />
|
||||
<PackageReference Include="System.ServiceProcess.ServiceController" Version="10.0.3" />
|
||||
<PackageReference Include="System.Threading.Channels" Version="10.0.3" />
|
||||
<PackageReference Include="YamlDotNet.Signed" Version="5.3.0" />
|
||||
<PackageReference Include="Microsoft.DevTunnels.Connections" Version="1.3.39" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -186,7 +186,16 @@
|
||||
"vars",
|
||||
"needs",
|
||||
"strategy",
|
||||
"matrix"
|
||||
"matrix",
|
||||
"steps",
|
||||
"job",
|
||||
"runner",
|
||||
"env",
|
||||
"always(0,0)",
|
||||
"failure(0,0)",
|
||||
"cancelled(0,0)",
|
||||
"success(0,0)",
|
||||
"hashFiles(1,255)"
|
||||
],
|
||||
"string": {}
|
||||
},
|
||||
|
||||
@@ -2291,6 +2291,10 @@ namespace GitHub.Actions.WorkflowParser.Conversion
|
||||
new NamedValueInfo<NoOperationNamedValue>(WorkflowTemplateConstants.Needs),
|
||||
new NamedValueInfo<NoOperationNamedValue>(WorkflowTemplateConstants.Strategy),
|
||||
new NamedValueInfo<NoOperationNamedValue>(WorkflowTemplateConstants.Matrix),
|
||||
new NamedValueInfo<NoOperationNamedValue>(WorkflowTemplateConstants.Steps),
|
||||
new NamedValueInfo<NoOperationNamedValue>(WorkflowTemplateConstants.Job),
|
||||
new NamedValueInfo<NoOperationNamedValue>(WorkflowTemplateConstants.Runner),
|
||||
new NamedValueInfo<NoOperationNamedValue>(WorkflowTemplateConstants.Env),
|
||||
};
|
||||
private static readonly IFunctionInfo[] s_jobConditionFunctions = new IFunctionInfo[]
|
||||
{
|
||||
@@ -2307,6 +2311,13 @@ namespace GitHub.Actions.WorkflowParser.Conversion
|
||||
new FunctionInfo<NoOperation>(WorkflowTemplateConstants.Success, 0, 0),
|
||||
new FunctionInfo<NoOperation>(WorkflowTemplateConstants.HashFiles, 1, Byte.MaxValue),
|
||||
};
|
||||
private static readonly IFunctionInfo[] s_snapshotConditionFunctions = null;
|
||||
private static readonly IFunctionInfo[] s_snapshotConditionFunctions = new IFunctionInfo[]
|
||||
{
|
||||
new FunctionInfo<NoOperation>(WorkflowTemplateConstants.Always, 0, 0),
|
||||
new FunctionInfo<NoOperation>(WorkflowTemplateConstants.Cancelled, 0, 0),
|
||||
new FunctionInfo<NoOperation>(WorkflowTemplateConstants.Failure, 0, 0),
|
||||
new FunctionInfo<NoOperation>(WorkflowTemplateConstants.Success, 0, 0),
|
||||
new FunctionInfo<NoOperation>(WorkflowTemplateConstants.HashFiles, 1, Byte.MaxValue),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2196,7 +2196,16 @@
|
||||
"vars",
|
||||
"needs",
|
||||
"strategy",
|
||||
"matrix"
|
||||
"matrix",
|
||||
"steps",
|
||||
"job",
|
||||
"runner",
|
||||
"env",
|
||||
"always(0,0)",
|
||||
"failure(0,0)",
|
||||
"cancelled(0,0)",
|
||||
"success(0,0)",
|
||||
"hashFiles(1,255)"
|
||||
],
|
||||
"description": "Use the if conditional to prevent a snapshot from being taken unless a condition is met. Any supported context and expression can be used to create a conditional. Expressions in an `if` conditional do not require the bracketed expression syntax. When you use expressions in an `if` conditional, you may omit the expression syntax because GitHub automatically evaluates the `if` conditional as an expression.",
|
||||
"string": {
|
||||
|
||||
@@ -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('\'', '"');
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
@@ -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;
|
||||
|
||||
@@ -867,5 +871,795 @@ namespace GitHub.Runner.Common.Tests.Worker
|
||||
Assert.Equal(completedTask, finished);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async Task WelcomeMessageSendsDefaultHelpWhenOverrideDisabled()
|
||||
{
|
||||
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();
|
||||
|
||||
await SendRequestAsync(stream, new Request
|
||||
{
|
||||
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 WelcomeMessageShowsCustomMessageWhenOverrideEnabled()
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
var port = GetFreePort();
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
var jobContext = CreateJobContextWithTunnel(cts.Token, port,
|
||||
overrideWelcomeMessage: true,
|
||||
welcomeMessage: "Welcome to debugging!");
|
||||
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"
|
||||
});
|
||||
|
||||
// First: configurationDone response
|
||||
var configDoneResponse = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
|
||||
Assert.Contains("\"command\":\"configurationDone\"", configDoneResponse);
|
||||
|
||||
// Second: custom welcome message
|
||||
var welcomeMsg = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
|
||||
Assert.Contains("\"event\":\"output\"", welcomeMsg);
|
||||
Assert.Contains("Welcome to debugging!", welcomeMsg);
|
||||
|
||||
await _debugger.StopAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async Task WelcomeMessageSuppressedWhenOverrideEnabledWithEmptyMessage()
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
var port = GetFreePort();
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
var jobContext = CreateJobContextWithTunnel(cts.Token, port,
|
||||
overrideWelcomeMessage: true,
|
||||
welcomeMessage: "");
|
||||
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 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();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Phase 2c: synthesized execution view as DAP source.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
private static Mock<GitHub.Runner.Worker.IActionRunner> NewActionRunner(
|
||||
GitHub.Runner.Worker.ActionRunStage stage,
|
||||
string displayName,
|
||||
string actionName = "actions/checkout",
|
||||
string actionRef = "v4")
|
||||
{
|
||||
var mock = new Mock<GitHub.Runner.Worker.IActionRunner>();
|
||||
mock.SetupGet(x => x.Stage).Returns(stage);
|
||||
mock.SetupGet(x => x.DisplayName).Returns(displayName);
|
||||
mock.SetupGet(x => x.Action).Returns(new GitHub.DistributedTask.Pipelines.ActionStep
|
||||
{
|
||||
Reference = new GitHub.DistributedTask.Pipelines.RepositoryPathReference
|
||||
{
|
||||
Name = actionName,
|
||||
Ref = actionRef,
|
||||
},
|
||||
});
|
||||
return mock;
|
||||
}
|
||||
|
||||
private async Task DriveDebuggerToReadyAsync(int port)
|
||||
{
|
||||
var waitTask = _debugger.WaitUntilReadyAsync();
|
||||
var client = new TcpClient();
|
||||
await client.ConnectAsync(IPAddress.Loopback, port);
|
||||
var stream = client.GetStream();
|
||||
await SendRequestAsync(stream, new Request
|
||||
{
|
||||
Seq = 1,
|
||||
Type = "request",
|
||||
Command = "configurationDone",
|
||||
});
|
||||
await waitTask;
|
||||
// Hold the client alive via GC root in caller scope through field.
|
||||
_liveDriveClient = client;
|
||||
}
|
||||
|
||||
private TcpClient _liveDriveClient;
|
||||
|
||||
private static Request MakeRequest(string command, object args = null)
|
||||
{
|
||||
return new Request
|
||||
{
|
||||
Seq = 1,
|
||||
Type = "request",
|
||||
Command = command,
|
||||
Arguments = args == null ? null : Newtonsoft.Json.Linq.JObject.FromObject(args),
|
||||
};
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async Task HandleSource_ReturnsExecutionViewYaml()
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
var port = GetFreePort();
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
var jobContext = CreateJobContextWithTunnel(cts.Token, port, "ci-job");
|
||||
await _debugger.StartAsync(jobContext.Object);
|
||||
try
|
||||
{
|
||||
await DriveDebuggerToReadyAsync(port);
|
||||
var step = NewActionRunner(GitHub.Runner.Worker.ActionRunStage.Main, "Run").Object;
|
||||
await _debugger.OnJobStepsInitializedAsync(new[] { step }, Array.Empty<IStep>());
|
||||
|
||||
var response = _debugger.HandleSource(MakeRequest("source", new SourceArguments { SourceReference = 1 }));
|
||||
Assert.True(response.Success);
|
||||
var body = Assert.IsType<SourceResponseBody>(response.Body);
|
||||
Assert.Equal(_debugger.ExecutionView.Yaml, body.Content);
|
||||
Assert.Null(body.MimeType);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await _debugger.StopAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async Task HandleSource_UnknownReference_Fails()
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
var port = GetFreePort();
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
var jobContext = CreateJobContextWithTunnel(cts.Token, port, "ci-job");
|
||||
await _debugger.StartAsync(jobContext.Object);
|
||||
try
|
||||
{
|
||||
await DriveDebuggerToReadyAsync(port);
|
||||
var step = NewActionRunner(GitHub.Runner.Worker.ActionRunStage.Main, "Run").Object;
|
||||
await _debugger.OnJobStepsInitializedAsync(new[] { step }, Array.Empty<IStep>());
|
||||
|
||||
var response = _debugger.HandleSource(MakeRequest("source", new SourceArguments { SourceReference = 999 }));
|
||||
Assert.False(response.Success);
|
||||
Assert.Contains("Unknown source reference", response.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await _debugger.StopAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async Task HandleSource_NoView_Fails()
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
var port = GetFreePort();
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
var jobContext = CreateJobContextWithTunnel(cts.Token, port, "ci-job");
|
||||
await _debugger.StartAsync(jobContext.Object);
|
||||
try
|
||||
{
|
||||
await DriveDebuggerToReadyAsync(port);
|
||||
// No OnJobStepsInitializedAsync call — view is null.
|
||||
var response = _debugger.HandleSource(MakeRequest("source", new SourceArguments { SourceReference = 1 }));
|
||||
Assert.False(response.Success);
|
||||
Assert.Contains("Execution view not yet available", response.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await _debugger.StopAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async Task HandleSource_OmitsMimeType()
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
var port = GetFreePort();
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
var jobContext = CreateJobContextWithTunnel(cts.Token, port, "ci-job");
|
||||
await _debugger.StartAsync(jobContext.Object);
|
||||
try
|
||||
{
|
||||
await DriveDebuggerToReadyAsync(port);
|
||||
var step = NewActionRunner(GitHub.Runner.Worker.ActionRunStage.Main, "Run").Object;
|
||||
await _debugger.OnJobStepsInitializedAsync(new[] { step }, Array.Empty<IStep>());
|
||||
|
||||
var response = _debugger.HandleSource(MakeRequest("source", new SourceArguments { SourceReference = 1 }));
|
||||
Assert.True(response.Success);
|
||||
var body = Assert.IsType<SourceResponseBody>(response.Body);
|
||||
// MimeType is intentionally omitted so DAP clients fall through
|
||||
// to path-extension detection (`.yml`) — the most consistently
|
||||
// honored mechanism across VS Code, nvim-dap, and JetBrains.
|
||||
Assert.Null(body.MimeType);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await _debugger.StopAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async Task HandleSource_MasksSecrets()
|
||||
{
|
||||
const string Secret = "supersecret-shhh-value";
|
||||
using (var hc = CreateTestContext())
|
||||
{
|
||||
hc.SecretMasker.AddValue(Secret);
|
||||
|
||||
var port = GetFreePort();
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
var jobContext = CreateJobContextWithTunnel(cts.Token, port, "ci-job");
|
||||
await _debugger.StartAsync(jobContext.Object);
|
||||
try
|
||||
{
|
||||
await DriveDebuggerToReadyAsync(port);
|
||||
// Embed the secret in a step display name so it ends up in the rendered YAML.
|
||||
var step = NewActionRunner(GitHub.Runner.Worker.ActionRunStage.Main, $"Run {Secret} step").Object;
|
||||
await _debugger.OnJobStepsInitializedAsync(new[] { step }, Array.Empty<IStep>());
|
||||
|
||||
// Sanity-check the raw view actually contains the secret;
|
||||
// otherwise the masking assertion below would pass vacuously.
|
||||
Assert.Contains(Secret, _debugger.ExecutionView.Yaml);
|
||||
|
||||
var response = _debugger.HandleSource(MakeRequest("source", new SourceArguments { SourceReference = 1 }));
|
||||
Assert.True(response.Success);
|
||||
var body = Assert.IsType<SourceResponseBody>(response.Body);
|
||||
Assert.DoesNotContain(Secret, body.Content);
|
||||
Assert.Contains("***", body.Content);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await _debugger.StopAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async Task HandleStackTrace_MasksSecretsInSourcePath()
|
||||
{
|
||||
const string Secret = "secret-job-name-xyz";
|
||||
using (var hc = CreateTestContext())
|
||||
{
|
||||
hc.SecretMasker.AddValue(Secret);
|
||||
|
||||
var port = GetFreePort();
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
// Job name (== GitHub context "job") embeds the secret.
|
||||
var jobContext = CreateJobContextWithTunnel(cts.Token, port, Secret);
|
||||
await _debugger.StartAsync(jobContext.Object);
|
||||
try
|
||||
{
|
||||
await DriveDebuggerToReadyAsync(port);
|
||||
var step = NewActionRunner(GitHub.Runner.Worker.ActionRunStage.Main, "Run").Object;
|
||||
await _debugger.OnJobStepsInitializedAsync(new[] { step }, Array.Empty<IStep>());
|
||||
|
||||
_ = _debugger.OnStepStartingAsync(step, isFirstStep: false);
|
||||
await Task.Delay(50);
|
||||
|
||||
var response = _debugger.HandleStackTrace(MakeRequest("stackTrace"));
|
||||
var body = Assert.IsType<StackTraceResponseBody>(response.Body);
|
||||
Assert.NotEmpty(body.StackFrames);
|
||||
foreach (var frame in body.StackFrames)
|
||||
{
|
||||
if (frame.Source != null)
|
||||
{
|
||||
Assert.DoesNotContain(Secret, frame.Source.Path ?? string.Empty);
|
||||
Assert.DoesNotContain(Secret, frame.Source.Name ?? string.Empty);
|
||||
Assert.Contains("***", frame.Source.Path ?? string.Empty);
|
||||
}
|
||||
Assert.DoesNotContain(Secret, frame.Name ?? string.Empty);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
await _debugger.StopAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async Task HandleStackTrace_TwoFramesWhenStepping()
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
var port = GetFreePort();
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
var jobContext = CreateJobContextWithTunnel(cts.Token, port, "ci-job");
|
||||
await _debugger.StartAsync(jobContext.Object);
|
||||
try
|
||||
{
|
||||
await DriveDebuggerToReadyAsync(port);
|
||||
var step1 = NewActionRunner(GitHub.Runner.Worker.ActionRunStage.Main, "Step One").Object;
|
||||
var step2 = NewActionRunner(GitHub.Runner.Worker.ActionRunStage.Main, "Step Two", "actions/setup-node", "v3").Object;
|
||||
await _debugger.OnJobStepsInitializedAsync(new[] { step1, step2 }, Array.Empty<IStep>());
|
||||
|
||||
// Fire-and-forget: first step pauses, but we just want _currentStep set.
|
||||
_ = _debugger.OnStepStartingAsync(step1, isFirstStep: false);
|
||||
await Task.Delay(50);
|
||||
|
||||
var response = _debugger.HandleStackTrace(MakeRequest("stackTrace"));
|
||||
var body = Assert.IsType<StackTraceResponseBody>(response.Body);
|
||||
Assert.Equal(2, body.StackFrames.Count);
|
||||
|
||||
var frame0 = body.StackFrames[0];
|
||||
Assert.Equal(1, frame0.Source.SourceReference);
|
||||
Assert.Equal(_debugger.ExecutionView.TryGetLineForStep(step1), frame0.Line);
|
||||
Assert.Equal("normal", frame0.PresentationHint);
|
||||
|
||||
var frame1 = body.StackFrames[1];
|
||||
Assert.Equal(1, frame1.Line);
|
||||
Assert.Equal("subtle", frame1.PresentationHint);
|
||||
Assert.Equal(1, frame1.Source.SourceReference);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await _debugger.StopAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async Task HandleStackTrace_OneFrameWhenViewMissing()
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
var port = GetFreePort();
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
var jobContext = CreateJobContextWithTunnel(cts.Token, port, "ci-job");
|
||||
await _debugger.StartAsync(jobContext.Object);
|
||||
try
|
||||
{
|
||||
await DriveDebuggerToReadyAsync(port);
|
||||
var step = NewActionRunner(GitHub.Runner.Worker.ActionRunStage.Main, "Lonely").Object;
|
||||
// No view built — but pause the step so _currentStep is set.
|
||||
_ = _debugger.OnStepStartingAsync(step, isFirstStep: false);
|
||||
await Task.Delay(50);
|
||||
|
||||
var response = _debugger.HandleStackTrace(MakeRequest("stackTrace"));
|
||||
var body = Assert.IsType<StackTraceResponseBody>(response.Body);
|
||||
Assert.Single(body.StackFrames);
|
||||
Assert.Null(body.StackFrames[0].Source);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await _debugger.StopAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async Task HandleStackTrace_NoFramesWhenIdle()
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
var port = GetFreePort();
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
var jobContext = CreateJobContextWithTunnel(cts.Token, port, "ci-job");
|
||||
await _debugger.StartAsync(jobContext.Object);
|
||||
try
|
||||
{
|
||||
await DriveDebuggerToReadyAsync(port);
|
||||
var response = _debugger.HandleStackTrace(MakeRequest("stackTrace"));
|
||||
var body = Assert.IsType<StackTraceResponseBody>(response.Body);
|
||||
Assert.Empty(body.StackFrames);
|
||||
Assert.Equal(0, body.TotalFrames);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await _debugger.StopAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async Task HandleLoadedSources_ReturnsExecutionView()
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
var port = GetFreePort();
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
var jobContext = CreateJobContextWithTunnel(cts.Token, port, "ci-job");
|
||||
await _debugger.StartAsync(jobContext.Object);
|
||||
try
|
||||
{
|
||||
await DriveDebuggerToReadyAsync(port);
|
||||
var step = NewActionRunner(GitHub.Runner.Worker.ActionRunStage.Main, "Run").Object;
|
||||
await _debugger.OnJobStepsInitializedAsync(new[] { step }, Array.Empty<IStep>());
|
||||
|
||||
var response = _debugger.HandleLoadedSources(MakeRequest("loadedSources"));
|
||||
Assert.True(response.Success);
|
||||
var body = Assert.IsType<LoadedSourcesResponseBody>(response.Body);
|
||||
Assert.Single(body.Sources);
|
||||
Assert.Equal("execution.yml", body.Sources[0].Name);
|
||||
Assert.Equal("ci-job/execution.yml", body.Sources[0].Path);
|
||||
Assert.Equal(1, body.Sources[0].SourceReference);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await _debugger.StopAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async Task HandleLoadedSources_NoView_ReturnsEmpty()
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
var port = GetFreePort();
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
var jobContext = CreateJobContextWithTunnel(cts.Token, port, "ci-job");
|
||||
await _debugger.StartAsync(jobContext.Object);
|
||||
try
|
||||
{
|
||||
await DriveDebuggerToReadyAsync(port);
|
||||
var response = _debugger.HandleLoadedSources(MakeRequest("loadedSources"));
|
||||
Assert.True(response.Success);
|
||||
var body = Assert.IsType<LoadedSourcesResponseBody>(response.Body);
|
||||
Assert.Empty(body.Sources);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await _debugger.StopAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async Task HandleSetBreakpoints_ReturnsUnverifiedPlaceholders()
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
var port = GetFreePort();
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
var jobContext = CreateJobContextWithTunnel(cts.Token, port, "ci-job");
|
||||
await _debugger.StartAsync(jobContext.Object);
|
||||
try
|
||||
{
|
||||
await DriveDebuggerToReadyAsync(port);
|
||||
var step = NewActionRunner(GitHub.Runner.Worker.ActionRunStage.Main, "Run").Object;
|
||||
await _debugger.OnJobStepsInitializedAsync(new[] { step }, Array.Empty<IStep>());
|
||||
|
||||
var args = new SetBreakpointsArguments
|
||||
{
|
||||
Source = new Source { SourceReference = 1 },
|
||||
Breakpoints = new System.Collections.Generic.List<SourceBreakpoint>
|
||||
{
|
||||
new SourceBreakpoint { Line = 5 },
|
||||
new SourceBreakpoint { Line = 10 },
|
||||
new SourceBreakpoint { Line = 15 },
|
||||
},
|
||||
};
|
||||
|
||||
var response = _debugger.HandleSetBreakpoints(MakeRequest("setBreakpoints", args));
|
||||
Assert.True(response.Success);
|
||||
var body = Assert.IsType<SetBreakpointsResponseBody>(response.Body);
|
||||
Assert.Equal(3, body.Breakpoints.Count);
|
||||
Assert.All(body.Breakpoints, bp => Assert.False(bp.Verified));
|
||||
Assert.Equal(new[] { 5, 10, 15 }, body.Breakpoints.ConvertAll(b => b.Line ?? -1));
|
||||
Assert.All(body.Breakpoints, bp => Assert.False(string.IsNullOrEmpty(bp.Message)));
|
||||
}
|
||||
finally
|
||||
{
|
||||
await _debugger.StopAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async Task OnJobStepsInitialized_EmitsLoadedSourceNewEvent()
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
var port = GetFreePort();
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
var jobContext = CreateJobContextWithTunnel(cts.Token, port, "ci-job");
|
||||
await _debugger.StartAsync(jobContext.Object);
|
||||
using var client = await ConnectClientAsync(port);
|
||||
try
|
||||
{
|
||||
var stream = client.GetStream();
|
||||
await SendRequestAsync(stream, new Request { Seq = 1, Type = "request", Command = "configurationDone" });
|
||||
await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); // configurationDone response
|
||||
await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); // welcome output event
|
||||
await _debugger.WaitUntilReadyAsync();
|
||||
|
||||
var step = NewActionRunner(GitHub.Runner.Worker.ActionRunStage.Main, "Run").Object;
|
||||
await _debugger.OnJobStepsInitializedAsync(new[] { step }, Array.Empty<IStep>());
|
||||
|
||||
var msg = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
|
||||
Assert.Contains("\"event\":\"loadedSource\"", msg);
|
||||
Assert.Contains("\"reason\":\"new\"", msg);
|
||||
Assert.Contains("\"sourceReference\":1", msg);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await _debugger.StopAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async Task OnPostStepRegistered_EmitsLoadedSourceChangedEvent()
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
var port = GetFreePort();
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
var jobContext = CreateJobContextWithTunnel(cts.Token, port, "ci-job");
|
||||
await _debugger.StartAsync(jobContext.Object);
|
||||
using var client = await ConnectClientAsync(port);
|
||||
try
|
||||
{
|
||||
var stream = client.GetStream();
|
||||
await SendRequestAsync(stream, new Request { Seq = 1, Type = "request", Command = "configurationDone" });
|
||||
await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); // configurationDone response
|
||||
await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5)); // welcome output event
|
||||
await _debugger.WaitUntilReadyAsync();
|
||||
|
||||
var main = NewActionRunner(GitHub.Runner.Worker.ActionRunStage.Main, "Run").Object;
|
||||
await _debugger.OnJobStepsInitializedAsync(new[] { main }, Array.Empty<IStep>());
|
||||
// Drain the "new" loadedSource event.
|
||||
await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
|
||||
|
||||
var post = NewActionRunner(GitHub.Runner.Worker.ActionRunStage.Post, "Post Run", "actions/cache", "v3").Object;
|
||||
_debugger.OnPostStepRegistered(post);
|
||||
|
||||
var msg = await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
|
||||
Assert.Contains("\"event\":\"loadedSource\"", msg);
|
||||
Assert.Contains("\"reason\":\"changed\"", msg);
|
||||
Assert.Contains("\"sourceReference\":1", msg);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await _debugger.StopAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async Task StackTrace_LineUpdatesAsStepsAdvance()
|
||||
{
|
||||
using (CreateTestContext())
|
||||
{
|
||||
var port = GetFreePort();
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
var jobContext = CreateJobContextWithTunnel(cts.Token, port, "ci-job");
|
||||
await _debugger.StartAsync(jobContext.Object);
|
||||
try
|
||||
{
|
||||
await DriveDebuggerToReadyAsync(port);
|
||||
var s1 = NewActionRunner(GitHub.Runner.Worker.ActionRunStage.Main, "Step 1", "a/b", "v1").Object;
|
||||
var s2 = NewActionRunner(GitHub.Runner.Worker.ActionRunStage.Main, "Step 2", "c/d", "v2").Object;
|
||||
await _debugger.OnJobStepsInitializedAsync(new[] { s1, s2 }, Array.Empty<IStep>());
|
||||
|
||||
_ = _debugger.OnStepStartingAsync(s1, isFirstStep: true);
|
||||
await Task.Delay(50);
|
||||
|
||||
var first = _debugger.HandleStackTrace(MakeRequest("stackTrace"));
|
||||
var firstBody = Assert.IsType<StackTraceResponseBody>(first.Body);
|
||||
int firstLine = firstBody.StackFrames[0].Line;
|
||||
Assert.Equal(_debugger.ExecutionView.TryGetLineForStep(s1), firstLine);
|
||||
|
||||
_ = _debugger.OnStepStartingAsync(s2, isFirstStep: false);
|
||||
await Task.Delay(50);
|
||||
|
||||
var second = _debugger.HandleStackTrace(MakeRequest("stackTrace"));
|
||||
var secondBody = Assert.IsType<StackTraceResponseBody>(second.Body);
|
||||
int secondLine = secondBody.StackFrames[0].Line;
|
||||
Assert.Equal(_debugger.ExecutionView.TryGetLineForStep(s2), secondLine);
|
||||
Assert.NotEqual(firstLine, secondLine);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await _debugger.StopAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ 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;
|
||||
@@ -405,6 +406,7 @@ 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();
|
||||
@@ -503,6 +505,7 @@ 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();
|
||||
@@ -544,6 +547,75 @@ 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")]
|
||||
|
||||
442
src/Test/L0/Worker/JobExecutionViewL0.cs
Normal file
442
src/Test/L0/Worker/JobExecutionViewL0.cs
Normal file
@@ -0,0 +1,442 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using GitHub.Runner.Worker;
|
||||
using GitHub.Runner.Worker.Dap;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace GitHub.Runner.Common.Tests.Worker
|
||||
{
|
||||
public sealed class JobExecutionViewL0
|
||||
{
|
||||
private static JobExecutionViewEntry MainEntry(string name)
|
||||
{
|
||||
return new JobExecutionViewEntry(JobExecutionPhase.Main, name, run: name);
|
||||
}
|
||||
|
||||
private static IStep NewStep(string displayName = "step")
|
||||
{
|
||||
var mock = new Mock<IStep>();
|
||||
mock.Setup(s => s.DisplayName).Returns(displayName);
|
||||
return mock.Object;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Constructor_RendersEmptyView()
|
||||
{
|
||||
var view = new JobExecutionView("my-job");
|
||||
|
||||
Assert.Equal(0, view.EntryCount);
|
||||
Assert.Contains("# Job: my-job", view.Yaml);
|
||||
Assert.Contains("- step: Setup job", view.Yaml);
|
||||
Assert.Contains("- step: Complete job", view.Yaml);
|
||||
|
||||
// Only the two synthetic boundaries appear.
|
||||
int stepCount = view.Yaml.Split("- step: ").Length - 1;
|
||||
Assert.Equal(2, stepCount);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void Constructor_ThrowsOnInvalidJobId(string jobId)
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => new JobExecutionView(jobId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Append_IncrementsEntryCount()
|
||||
{
|
||||
var view = new JobExecutionView("j");
|
||||
|
||||
int line0 = view.Append(MainEntry("a"));
|
||||
int line1 = view.Append(MainEntry("b"));
|
||||
int line2 = view.Append(MainEntry("c"));
|
||||
|
||||
Assert.Equal(3, view.EntryCount);
|
||||
Assert.True(line0 < line1);
|
||||
Assert.True(line1 < line2);
|
||||
Assert.Equal(line0, view.GetLine(0));
|
||||
Assert.Equal(line1, view.GetLine(1));
|
||||
Assert.Equal(line2, view.GetLine(2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Append_PreservesPriorEntryLines()
|
||||
{
|
||||
var view = new JobExecutionView("j");
|
||||
|
||||
int l0 = view.Append(MainEntry("a"));
|
||||
int l1 = view.Append(MainEntry("b"));
|
||||
int l2 = view.Append(MainEntry("c"));
|
||||
|
||||
view.Append(MainEntry("d"));
|
||||
Assert.Equal(l0, view.GetLine(0));
|
||||
Assert.Equal(l1, view.GetLine(1));
|
||||
Assert.Equal(l2, view.GetLine(2));
|
||||
|
||||
view.Append(MainEntry("e"));
|
||||
Assert.Equal(l0, view.GetLine(0));
|
||||
Assert.Equal(l1, view.GetLine(1));
|
||||
Assert.Equal(l2, view.GetLine(2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Append_RegistersStepIdentity()
|
||||
{
|
||||
var view = new JobExecutionView("j");
|
||||
var step = NewStep();
|
||||
|
||||
int line = view.Append(MainEntry("a"), step);
|
||||
|
||||
Assert.Equal(line, view.GetLine(0));
|
||||
Assert.Equal(line, view.TryGetLineForStep(step));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Append_NullStepIdentity_StillAppends()
|
||||
{
|
||||
var view = new JobExecutionView("j");
|
||||
|
||||
view.Append(MainEntry("a"), stepIdentity: null);
|
||||
|
||||
Assert.Equal(1, view.EntryCount);
|
||||
Assert.Null(view.TryGetLineForStep(null));
|
||||
Assert.Contains("- step: a", view.Yaml);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Append_DuplicateStepIdentity_Throws()
|
||||
{
|
||||
var view = new JobExecutionView("j");
|
||||
var step = NewStep();
|
||||
|
||||
view.Append(MainEntry("a"), step);
|
||||
Assert.Throws<InvalidOperationException>(() => view.Append(MainEntry("b"), step));
|
||||
|
||||
// State preserved: only the first entry is present.
|
||||
Assert.Equal(1, view.EntryCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Append_NullEntry_Throws()
|
||||
{
|
||||
var view = new JobExecutionView("j");
|
||||
Assert.Throws<ArgumentNullException>(() => view.Append(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void AppendRange_AppendsAllAndRendersOnce()
|
||||
{
|
||||
var view = new JobExecutionView("j");
|
||||
var steps = Enumerable.Range(0, 5).Select(i => NewStep("s" + i)).ToList();
|
||||
var items = steps
|
||||
.Select((s, i) => (entry: MainEntry("e" + i), stepIdentity: s))
|
||||
.ToList();
|
||||
|
||||
view.AppendRange(items);
|
||||
|
||||
Assert.Equal(5, view.EntryCount);
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
int line = view.GetLine(i);
|
||||
Assert.Equal(line, view.TryGetLineForStep(steps[i]));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void AppendRange_RejectsDuplicateInInput()
|
||||
{
|
||||
var view = new JobExecutionView("j");
|
||||
var dup = NewStep();
|
||||
var items = new List<(JobExecutionViewEntry, IStep)>
|
||||
{
|
||||
(MainEntry("a"), dup),
|
||||
(MainEntry("b"), dup),
|
||||
};
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => view.AppendRange(items));
|
||||
Assert.Equal(0, view.EntryCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void AppendRange_RejectsOverlapWithExisting()
|
||||
{
|
||||
var view = new JobExecutionView("j");
|
||||
var step = NewStep();
|
||||
view.Append(MainEntry("a"), step);
|
||||
|
||||
var items = new List<(JobExecutionViewEntry, IStep)>
|
||||
{
|
||||
(MainEntry("b"), step),
|
||||
};
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => view.AppendRange(items));
|
||||
Assert.Equal(1, view.EntryCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void AppendRange_NullItems_Throws()
|
||||
{
|
||||
var view = new JobExecutionView("j");
|
||||
Assert.Throws<ArgumentNullException>(() => view.AppendRange(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void TryGetLineForStep_NullStep_ReturnsNull()
|
||||
{
|
||||
var view = new JobExecutionView("j");
|
||||
Assert.Null(view.TryGetLineForStep(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void TryGetLineForStep_UnknownStep_ReturnsNull()
|
||||
{
|
||||
var view = new JobExecutionView("j");
|
||||
var step = NewStep();
|
||||
Assert.Null(view.TryGetLineForStep(step));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
[InlineData(-1)]
|
||||
[InlineData(2)]
|
||||
public void GetLine_OutOfRange_Throws(int index)
|
||||
{
|
||||
var view = new JobExecutionView("j");
|
||||
view.Append(MainEntry("a"));
|
||||
view.Append(MainEntry("b"));
|
||||
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => view.GetLine(index));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Yaml_UpdatesAfterAppend()
|
||||
{
|
||||
var view = new JobExecutionView("j");
|
||||
view.Append(MainEntry("first"));
|
||||
string before = view.Yaml;
|
||||
Assert.Contains("- step: first", before);
|
||||
|
||||
view.Append(MainEntry("second"));
|
||||
string after = view.Yaml;
|
||||
|
||||
Assert.Contains("- step: first", after);
|
||||
Assert.Contains("- step: second", after);
|
||||
Assert.NotEqual(before, after);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Yaml_AlwaysEndsWithCleanupBoundary()
|
||||
{
|
||||
var view = new JobExecutionView("j");
|
||||
Assert.EndsWith("cleanup:\n - step: Complete job\n", view.Yaml);
|
||||
|
||||
view.Append(MainEntry("a"));
|
||||
Assert.EndsWith("cleanup:\n - step: Complete job\n", view.Yaml);
|
||||
|
||||
view.Append(MainEntry("b"));
|
||||
view.Append(MainEntry("c"));
|
||||
Assert.EndsWith("cleanup:\n - step: Complete job\n", view.Yaml);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Append_WithMatchKey_TracksUnclaimed()
|
||||
{
|
||||
var view = new JobExecutionView("j");
|
||||
|
||||
int line = view.Append(MainEntry("placeholder"), stepIdentity: null, matchKey: "k1");
|
||||
|
||||
var step = NewStep("real");
|
||||
int? claimed = view.TryClaim("k1", step);
|
||||
Assert.Equal(line, claimed);
|
||||
Assert.Equal(line, view.TryGetLineForStep(step));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void TryClaim_UnknownKey_ReturnsNull()
|
||||
{
|
||||
var view = new JobExecutionView("j");
|
||||
view.Append(MainEntry("a"), stepIdentity: null, matchKey: "k1");
|
||||
|
||||
Assert.Null(view.TryClaim("nope", NewStep()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void TryClaim_AlreadyClaimed_ReturnsNull()
|
||||
{
|
||||
var view = new JobExecutionView("j");
|
||||
view.Append(MainEntry("a"), stepIdentity: null, matchKey: "k1");
|
||||
|
||||
var first = NewStep("first");
|
||||
Assert.NotNull(view.TryClaim("k1", first));
|
||||
|
||||
var second = NewStep("second");
|
||||
Assert.Null(view.TryClaim("k1", second));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void TryClaim_StepAlreadyRegistered_ReturnsNull()
|
||||
{
|
||||
var view = new JobExecutionView("j");
|
||||
var step = NewStep();
|
||||
// Step is registered for the first entry.
|
||||
view.Append(MainEntry("a"), step);
|
||||
// A placeholder is registered for the second entry.
|
||||
view.Append(MainEntry("b"), stepIdentity: null, matchKey: "k1");
|
||||
|
||||
// Trying to claim the placeholder with the already-registered
|
||||
// step must return null (defensive — would otherwise double-bind).
|
||||
Assert.Null(view.TryClaim("k1", step));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Append_DuplicateMatchKey_Throws()
|
||||
{
|
||||
var view = new JobExecutionView("j");
|
||||
view.Append(MainEntry("a"), stepIdentity: null, matchKey: "k1");
|
||||
|
||||
Assert.Throws<InvalidOperationException>(
|
||||
() => view.Append(MainEntry("b"), stepIdentity: null, matchKey: "k1"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Append_MatchKeyNull_BehavesLikeOldOverload()
|
||||
{
|
||||
var view = new JobExecutionView("j");
|
||||
var step = NewStep();
|
||||
|
||||
int line = view.Append(MainEntry("a"), step);
|
||||
|
||||
Assert.Equal(line, view.GetLine(0));
|
||||
Assert.Equal(line, view.TryGetLineForStep(step));
|
||||
// TryClaim with any key must return null since no matchKey was registered.
|
||||
Assert.Null(view.TryClaim("anything", NewStep()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void TryClaim_AfterClaim_TryGetLineForStepResolves()
|
||||
{
|
||||
var view = new JobExecutionView("j");
|
||||
int line = view.Append(MainEntry("placeholder"), stepIdentity: null, matchKey: "k1");
|
||||
|
||||
var step = NewStep();
|
||||
Assert.Equal(line, view.TryClaim("k1", step));
|
||||
Assert.Equal(line, view.TryGetLineForStep(step));
|
||||
|
||||
// And a later Append doesn't lose the claim (Render rebuilds
|
||||
// the IStep -> line map from the persisted identities).
|
||||
view.Append(MainEntry("b"));
|
||||
Assert.Equal(line, view.TryGetLineForStep(step));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void TryClaim_NullArgs_Throws()
|
||||
{
|
||||
var view = new JobExecutionView("j");
|
||||
Assert.Throws<ArgumentNullException>(() => view.TryClaim(null, NewStep()));
|
||||
Assert.Throws<ArgumentNullException>(() => view.TryClaim("k", null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async Task ConcurrentAppends_DontCorruptState()
|
||||
{
|
||||
var view = new JobExecutionView("j");
|
||||
const int N = 50;
|
||||
var steps = Enumerable.Range(0, N).Select(i => NewStep("s" + i)).ToList();
|
||||
var returnedLines = new ConcurrentBag<int>();
|
||||
|
||||
var tasks = Enumerable.Range(0, N).Select(i => Task.Run(() =>
|
||||
{
|
||||
int line = view.Append(MainEntry("e" + i), steps[i]);
|
||||
returnedLines.Add(line);
|
||||
})).ToArray();
|
||||
|
||||
await Task.WhenAll(tasks);
|
||||
|
||||
Assert.Equal(N, view.EntryCount);
|
||||
Assert.Equal(N, returnedLines.Distinct().Count());
|
||||
|
||||
// Every step identity resolves to some line in [0, N).
|
||||
var entryLines = Enumerable.Range(0, N).Select(view.GetLine).ToHashSet();
|
||||
Assert.Equal(N, entryLines.Count);
|
||||
foreach (var step in steps)
|
||||
{
|
||||
int? line = view.TryGetLineForStep(step);
|
||||
Assert.NotNull(line);
|
||||
Assert.Contains(line.Value, entryLines);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
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 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
660
src/Test/L0/Worker/JobExecutionViewLifecycleL0.cs
Normal file
660
src/Test/L0/Worker/JobExecutionViewLifecycleL0.cs
Normal file
@@ -0,0 +1,660 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
628
src/Test/L0/Worker/JobExecutionViewRendererL0.cs
Normal file
628
src/Test/L0/Worker/JobExecutionViewRendererL0.cs
Normal file
@@ -0,0 +1,628 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using GitHub.Runner.Worker.Dap;
|
||||
using Xunit;
|
||||
|
||||
namespace GitHub.Runner.Common.Tests.Worker
|
||||
{
|
||||
public sealed class JobExecutionViewRendererL0
|
||||
{
|
||||
// Verbatim expected YAML for the design doc's "Worked example".
|
||||
// The render output is structured as phase-keyed top-level sections;
|
||||
// there is no per-entry `phase:` field. The setup: and cleanup:
|
||||
// sections always render; pre:/main:/post: render only when
|
||||
// they contain at least one entry. The Main entries surface
|
||||
// user-authored step parameters pre-evaluation (no expression
|
||||
// substitution); Pre/Post entries stay minimal.
|
||||
private const string ExpectedWorkedExampleYaml =
|
||||
"# Job: build\n" +
|
||||
"# Runner execution plan — read-only.\n" +
|
||||
"\n" +
|
||||
"setup:\n" +
|
||||
" - step: Setup job\n" +
|
||||
"\n" +
|
||||
"pre:\n" +
|
||||
" - step: Pre actions/checkout@v4\n" +
|
||||
" action: actions/checkout@v4\n" +
|
||||
" - step: Pre actions/cache@v5\n" +
|
||||
" action: actions/cache@v5\n" +
|
||||
"\n" +
|
||||
"main:\n" +
|
||||
" - step: actions/checkout@v4\n" +
|
||||
" uses: actions/checkout@v4\n" +
|
||||
" source: .github/workflows/ci.yml:10\n" +
|
||||
" - step: Cache Primes\n" +
|
||||
" id: cache-primes\n" +
|
||||
" uses: actions/cache@v5\n" +
|
||||
" with:\n" +
|
||||
" path: prime-numbers\n" +
|
||||
" key: ${{ runner.os }}-primes\n" +
|
||||
" source: .github/workflows/ci.yml:12\n" +
|
||||
" - step: Run tests\n" +
|
||||
" id: test\n" +
|
||||
" run: |\n" +
|
||||
" echo starting\n" +
|
||||
" npm test\n" +
|
||||
" if: ${{ github.event_name == 'push' }}\n" +
|
||||
" env:\n" +
|
||||
" NODE_ENV: production\n" +
|
||||
" shell: bash\n" +
|
||||
" working-directory: ./api\n" +
|
||||
" source: .github/workflows/ci.yml:18\n" +
|
||||
" - step: npm ci\n" +
|
||||
" run: npm ci\n" +
|
||||
" source: .github/workflows/ci.yml:28\n" +
|
||||
"\n" +
|
||||
"post:\n" +
|
||||
" - step: Post actions/cache@v5\n" +
|
||||
" action: actions/cache@v5\n" +
|
||||
" - step: Post actions/checkout@v4\n" +
|
||||
" action: actions/checkout@v4\n" +
|
||||
"\n" +
|
||||
"cleanup:\n" +
|
||||
" - step: Complete job\n";
|
||||
|
||||
private static List<JobExecutionViewEntry> WorkedExampleEntries()
|
||||
{
|
||||
return new List<JobExecutionViewEntry>
|
||||
{
|
||||
new JobExecutionViewEntry(JobExecutionPhase.Pre, "Pre actions/checkout@v4", uses: "actions/checkout@v4"),
|
||||
new JobExecutionViewEntry(JobExecutionPhase.Pre, "Pre actions/cache@v5", uses: "actions/cache@v5"),
|
||||
new JobExecutionViewEntry(JobExecutionPhase.Main, "actions/checkout@v4", uses: "actions/checkout@v4", sourcePath: ".github/workflows/ci.yml", sourceLine: 10),
|
||||
new JobExecutionViewEntry(
|
||||
JobExecutionPhase.Main,
|
||||
"Cache Primes",
|
||||
uses: "actions/cache@v5",
|
||||
id: "cache-primes",
|
||||
withYaml: " path: prime-numbers\n key: ${{ runner.os }}-primes",
|
||||
sourcePath: ".github/workflows/ci.yml",
|
||||
sourceLine: 12),
|
||||
new JobExecutionViewEntry(
|
||||
JobExecutionPhase.Main,
|
||||
"Run tests",
|
||||
run: "echo starting\nnpm test",
|
||||
id: "test",
|
||||
@if: "${{ github.event_name == 'push' }}",
|
||||
envYaml: " NODE_ENV: production",
|
||||
shell: "bash",
|
||||
workingDirectory: "./api",
|
||||
sourcePath: ".github/workflows/ci.yml",
|
||||
sourceLine: 18),
|
||||
new JobExecutionViewEntry(JobExecutionPhase.Main, "npm ci", run: "npm ci", sourcePath: ".github/workflows/ci.yml", sourceLine: 28),
|
||||
new JobExecutionViewEntry(JobExecutionPhase.Post, "Post actions/cache@v5", uses: "actions/cache@v5"),
|
||||
new JobExecutionViewEntry(JobExecutionPhase.Post, "Post actions/checkout@v4", uses: "actions/checkout@v4"),
|
||||
};
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Render_MatchesDesignDocWorkedExample()
|
||||
{
|
||||
var entries = WorkedExampleEntries();
|
||||
|
||||
var result = JobExecutionViewRenderer.Render("build", entries);
|
||||
|
||||
Assert.Equal(ExpectedWorkedExampleYaml, result.Yaml);
|
||||
Assert.Equal(8, result.EntryStartLines.Count);
|
||||
var lines = result.Yaml.Split('\n');
|
||||
for (int i = 0; i < entries.Count; i++)
|
||||
{
|
||||
Assert.StartsWith(" - step: ", lines[result.EntryStartLines[i] - 1]);
|
||||
Assert.Contains(entries[i].DisplayName, lines[result.EntryStartLines[i] - 1]);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Render_AlwaysEmitsSetupAndCleanup()
|
||||
{
|
||||
var result = JobExecutionViewRenderer.Render("job-1", new List<JobExecutionViewEntry>());
|
||||
|
||||
const string expected =
|
||||
"# Job: job-1\n" +
|
||||
"# Runner execution plan — read-only.\n" +
|
||||
"\n" +
|
||||
"setup:\n" +
|
||||
" - step: Setup job\n" +
|
||||
"\n" +
|
||||
"cleanup:\n" +
|
||||
" - step: Complete job\n";
|
||||
Assert.Equal(expected, result.Yaml);
|
||||
Assert.Empty(result.EntryStartLines);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Render_OmitsEmptyOptionalSections()
|
||||
{
|
||||
// Only a Main entry — pre:/post: must not appear.
|
||||
var result = JobExecutionViewRenderer.Render("j", new[]
|
||||
{
|
||||
new JobExecutionViewEntry(JobExecutionPhase.Main, "echo", run: "echo hello"),
|
||||
});
|
||||
|
||||
Assert.Contains("setup:\n", result.Yaml);
|
||||
Assert.Contains("main:\n", result.Yaml);
|
||||
Assert.Contains("cleanup:\n", result.Yaml);
|
||||
Assert.DoesNotContain("\npre:\n", result.Yaml);
|
||||
Assert.DoesNotContain("\npost:\n", result.Yaml);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Render_EmitsPhaseSectionsInFixedOrder()
|
||||
{
|
||||
// Input order [Post, Pre, Main] should still render as setup → pre → main → post → cleanup.
|
||||
var entries = new[]
|
||||
{
|
||||
new JobExecutionViewEntry(JobExecutionPhase.Post, "post-a", uses: "a/b@v1"),
|
||||
new JobExecutionViewEntry(JobExecutionPhase.Pre, "pre-a", uses: "a/b@v1"),
|
||||
new JobExecutionViewEntry(JobExecutionPhase.Main, "main-a", uses: "a/b@v1"),
|
||||
};
|
||||
|
||||
var result = JobExecutionViewRenderer.Render("j", entries);
|
||||
string yaml = result.Yaml;
|
||||
|
||||
int setupIdx = yaml.IndexOf("setup:\n", StringComparison.Ordinal);
|
||||
int preIdx = yaml.IndexOf("\npre:\n", StringComparison.Ordinal);
|
||||
int mainIdx = yaml.IndexOf("\nmain:\n", StringComparison.Ordinal);
|
||||
int postIdx = yaml.IndexOf("\npost:\n", StringComparison.Ordinal);
|
||||
int cleanupIdx = yaml.IndexOf("\ncleanup:\n", StringComparison.Ordinal);
|
||||
Assert.True(setupIdx >= 0 && preIdx > setupIdx && mainIdx > preIdx && postIdx > mainIdx && cleanupIdx > postIdx,
|
||||
$"section ordering wrong: setup={setupIdx} pre={preIdx} main={mainIdx} post={postIdx} cleanup={cleanupIdx}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Render_StartLinesAlignWithInputOrder()
|
||||
{
|
||||
// Input order is [Pre, Main, Post]; output order is also pre/main/post,
|
||||
// but startLines must be indexed by INPUT position, not by section.
|
||||
var entries = new[]
|
||||
{
|
||||
new JobExecutionViewEntry(JobExecutionPhase.Pre, "pre-x", uses: "x/y@v1"), // index 0
|
||||
new JobExecutionViewEntry(JobExecutionPhase.Main, "main-x", uses: "x/y@v1"), // index 1
|
||||
new JobExecutionViewEntry(JobExecutionPhase.Post, "post-x", uses: "x/y@v1"), // index 2
|
||||
};
|
||||
|
||||
var result = JobExecutionViewRenderer.Render("j", entries);
|
||||
var lines = result.Yaml.Split('\n');
|
||||
|
||||
Assert.StartsWith(" - step: pre-x", lines[result.EntryStartLines[0] - 1]);
|
||||
Assert.StartsWith(" - step: main-x", lines[result.EntryStartLines[1] - 1]);
|
||||
Assert.StartsWith(" - step: post-x", lines[result.EntryStartLines[2] - 1]);
|
||||
// And input-order ordering of start lines is strictly increasing
|
||||
// when phases are in declaration order matching the section order.
|
||||
Assert.True(result.EntryStartLines[0] < result.EntryStartLines[1]);
|
||||
Assert.True(result.EntryStartLines[1] < result.EntryStartLines[2]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Render_StartLinesFollowInputOrderEvenWhenPhasesAreInterleaved()
|
||||
{
|
||||
// Input order is [Main A, Pre B, Main C]: pre section will render
|
||||
// first (Pre B) and main second (Main A then Main C). startLines
|
||||
// must still be indexed by input order.
|
||||
var entries = new[]
|
||||
{
|
||||
new JobExecutionViewEntry(JobExecutionPhase.Main, "main-a", uses: "a@v1"), // index 0 — renders in main section
|
||||
new JobExecutionViewEntry(JobExecutionPhase.Pre, "pre-b", uses: "b@v1"), // index 1 — renders in pre section
|
||||
new JobExecutionViewEntry(JobExecutionPhase.Main, "main-c", uses: "c@v1"), // index 2 — renders in main section
|
||||
};
|
||||
|
||||
var result = JobExecutionViewRenderer.Render("j", entries);
|
||||
var lines = result.Yaml.Split('\n');
|
||||
|
||||
Assert.StartsWith(" - step: main-a", lines[result.EntryStartLines[0] - 1]);
|
||||
Assert.StartsWith(" - step: pre-b", lines[result.EntryStartLines[1] - 1]);
|
||||
Assert.StartsWith(" - step: main-c", lines[result.EntryStartLines[2] - 1]);
|
||||
// The pre section comes before main: input-index-1 entry's line is
|
||||
// before input-index-0 entry's line.
|
||||
Assert.True(result.EntryStartLines[1] < result.EntryStartLines[0]);
|
||||
Assert.True(result.EntryStartLines[0] < result.EntryStartLines[2]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Render_EntryStartLinesPointAtStepKeys()
|
||||
{
|
||||
var entries = WorkedExampleEntries();
|
||||
var result = JobExecutionViewRenderer.Render("build", entries);
|
||||
var lines = result.Yaml.Split('\n');
|
||||
|
||||
for (int i = 0; i < result.EntryStartLines.Count; i++)
|
||||
{
|
||||
int oneBased = result.EntryStartLines[i];
|
||||
Assert.True(oneBased >= 1 && oneBased <= lines.Length, $"start line {oneBased} out of range");
|
||||
Assert.StartsWith(" - step: ", lines[oneBased - 1]);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Render_EntryStartLinesExcludeSetupAndCleanup()
|
||||
{
|
||||
var entries = WorkedExampleEntries();
|
||||
var result = JobExecutionViewRenderer.Render("build", entries);
|
||||
var lines = result.Yaml.Split('\n');
|
||||
|
||||
int setupLine = -1, cleanupLine = -1;
|
||||
for (int i = 0; i < lines.Length; i++)
|
||||
{
|
||||
if (lines[i] == " - step: Setup job") setupLine = i + 1;
|
||||
if (lines[i] == " - step: Complete job") cleanupLine = i + 1;
|
||||
}
|
||||
Assert.True(setupLine > 0 && cleanupLine > 0, "Setup/Cleanup lines must exist");
|
||||
Assert.DoesNotContain(setupLine, result.EntryStartLines);
|
||||
Assert.DoesNotContain(cleanupLine, result.EntryStartLines);
|
||||
}
|
||||
|
||||
[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")]
|
||||
public void Render_QuotesSpecialChars(string displayName)
|
||||
{
|
||||
// Round-trip the rendered YAML through YamlDotNet's deserializer
|
||||
// and assert the parsed step's display name matches the input.
|
||||
// This decouples the test from any specific quoting style.
|
||||
var entry = new JobExecutionViewEntry(JobExecutionPhase.Main, displayName);
|
||||
var result = JobExecutionViewRenderer.Render("j", new[] { entry });
|
||||
|
||||
var deserializer = new YamlDotNet.Serialization.DeserializerBuilder().Build();
|
||||
var doc = deserializer.Deserialize<Dictionary<string, List<Dictionary<string, object>>>>(result.Yaml);
|
||||
Assert.NotNull(doc);
|
||||
Assert.True(doc.ContainsKey("main"), "rendered YAML missing top-level 'main' key");
|
||||
var mainSteps = doc["main"];
|
||||
Assert.Single(mainSteps);
|
||||
Assert.Equal(displayName, mainSteps[0]["step"] as string);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Render_EmitsSourceAnnotationForMainStep()
|
||||
{
|
||||
var entry = new JobExecutionViewEntry(
|
||||
JobExecutionPhase.Main,
|
||||
"npm ci",
|
||||
run: "npm ci",
|
||||
sourcePath: ".github/workflows/ci.yml",
|
||||
sourceLine: 42);
|
||||
|
||||
var result = JobExecutionViewRenderer.Render("j", new[] { entry });
|
||||
|
||||
Assert.Contains(" source: .github/workflows/ci.yml:42\n", result.Yaml);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Render_OmitsSourceAnnotationForPreAndPost()
|
||||
{
|
||||
var pre = new JobExecutionViewEntry(
|
||||
JobExecutionPhase.Pre,
|
||||
"Pre actions/checkout@v4",
|
||||
uses: "actions/checkout@v4",
|
||||
sourcePath: ".github/workflows/ci.yml",
|
||||
sourceLine: 9);
|
||||
var post = new JobExecutionViewEntry(
|
||||
JobExecutionPhase.Post,
|
||||
"Post actions/checkout@v4",
|
||||
uses: "actions/checkout@v4",
|
||||
sourcePath: ".github/workflows/ci.yml",
|
||||
sourceLine: 9);
|
||||
|
||||
var result = JobExecutionViewRenderer.Render("j", new[] { pre, post });
|
||||
|
||||
Assert.DoesNotContain("source:", result.Yaml);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Render_EmitsMultilineRunAsBlockScalar()
|
||||
{
|
||||
var entry = new JobExecutionViewEntry(
|
||||
JobExecutionPhase.Main,
|
||||
"multi",
|
||||
run: "echo a\necho b\necho c");
|
||||
|
||||
var result = JobExecutionViewRenderer.Render("j", new[] { entry });
|
||||
|
||||
Assert.Contains(" run: |\n", result.Yaml);
|
||||
Assert.Contains(" echo a\n", result.Yaml);
|
||||
Assert.Contains(" echo b\n", result.Yaml);
|
||||
Assert.Contains(" echo c\n", result.Yaml);
|
||||
Assert.DoesNotContain("truncated", result.Yaml);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Render_EmitsAllUserAuthoredParamsForActionStep()
|
||||
{
|
||||
var entry = new JobExecutionViewEntry(
|
||||
JobExecutionPhase.Main,
|
||||
"Run action",
|
||||
uses: "actions/cache@v5",
|
||||
id: "cache-primes",
|
||||
@if: "${{ github.event_name == 'push' }}",
|
||||
continueOnError: "true",
|
||||
timeoutMinutes: "10",
|
||||
envYaml: " NODE_ENV: production",
|
||||
withYaml: " path: prime-numbers\n key: ${{ runner.os }}-primes",
|
||||
sourcePath: "ci.yml",
|
||||
sourceLine: 5);
|
||||
|
||||
var result = JobExecutionViewRenderer.Render("j", new[] { entry });
|
||||
|
||||
Assert.Contains(" id: cache-primes\n", result.Yaml);
|
||||
Assert.Contains(" uses: actions/cache@v5\n", result.Yaml);
|
||||
Assert.Contains(" continue-on-error: true\n", result.Yaml);
|
||||
Assert.Contains(" timeout-minutes: 10\n", result.Yaml);
|
||||
Assert.Contains(" env:\n NODE_ENV: production\n", result.Yaml);
|
||||
Assert.Contains(" with:\n path: prime-numbers\n key: ${{ runner.os }}-primes\n", result.Yaml);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Render_EmitsRunStepWithShellAndWorkingDirectory()
|
||||
{
|
||||
var entry = new JobExecutionViewEntry(
|
||||
JobExecutionPhase.Main,
|
||||
"Run tests",
|
||||
run: "echo starting\nnpm test",
|
||||
id: "test",
|
||||
shell: "bash",
|
||||
workingDirectory: "./api");
|
||||
|
||||
var result = JobExecutionViewRenderer.Render("j", new[] { entry });
|
||||
|
||||
Assert.Contains(" run: |\n echo starting\n npm test\n", result.Yaml);
|
||||
Assert.Contains(" shell: bash\n", result.Yaml);
|
||||
Assert.Contains(" working-directory: ./api\n", result.Yaml);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Render_PreservesExpressionsInRenderedYaml()
|
||||
{
|
||||
var entry = new JobExecutionViewEntry(
|
||||
JobExecutionPhase.Main,
|
||||
"Cache",
|
||||
uses: "actions/cache@v5",
|
||||
withYaml: " key: ${{ runner.os }}-primes");
|
||||
|
||||
var result = JobExecutionViewRenderer.Render("j", new[] { entry });
|
||||
|
||||
// Expressions render exactly as authored — no evaluation.
|
||||
Assert.Contains("${{ runner.os }}-primes", result.Yaml);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Render_PrePostStepsRemainMinimal()
|
||||
{
|
||||
// Even if a pre/post entry carries user-param fields (it shouldn't
|
||||
// in production, but the renderer must defensively drop them),
|
||||
// only step: + action: render for these phases.
|
||||
var pre = new JobExecutionViewEntry(
|
||||
JobExecutionPhase.Pre,
|
||||
"Pre actions/cache@v5",
|
||||
uses: "actions/cache@v5",
|
||||
id: "should-not-appear",
|
||||
envYaml: " X: y",
|
||||
withYaml: " key: nope");
|
||||
var post = new JobExecutionViewEntry(
|
||||
JobExecutionPhase.Post,
|
||||
"Post actions/cache@v5",
|
||||
uses: "actions/cache@v5",
|
||||
id: "should-not-appear",
|
||||
envYaml: " X: y");
|
||||
|
||||
var result = JobExecutionViewRenderer.Render("j", new[] { pre, post });
|
||||
|
||||
Assert.DoesNotContain("id:", result.Yaml);
|
||||
Assert.DoesNotContain("env:", result.Yaml);
|
||||
Assert.DoesNotContain("with:", result.Yaml);
|
||||
Assert.DoesNotContain("should-not-appear", result.Yaml);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Render_FieldOrderIsStable()
|
||||
{
|
||||
var entry = new JobExecutionViewEntry(
|
||||
JobExecutionPhase.Main,
|
||||
"Everything",
|
||||
uses: "actions/cache@v5",
|
||||
id: "x",
|
||||
@if: "always()",
|
||||
continueOnError: "false",
|
||||
timeoutMinutes: "5",
|
||||
envYaml: " A: 1",
|
||||
withYaml: " key: k",
|
||||
sourcePath: "ci.yml",
|
||||
sourceLine: 1);
|
||||
|
||||
var result = JobExecutionViewRenderer.Render("j", new[] { entry });
|
||||
var y = result.Yaml;
|
||||
int iStep = y.IndexOf(" - step: ", StringComparison.Ordinal) >= 0
|
||||
? y.IndexOf("- step:", StringComparison.Ordinal) : y.IndexOf("- step:", StringComparison.Ordinal);
|
||||
int iId = y.IndexOf(" id:", StringComparison.Ordinal);
|
||||
int iUses = y.IndexOf(" uses:", StringComparison.Ordinal);
|
||||
int iIf = y.IndexOf(" if:", StringComparison.Ordinal);
|
||||
int iCoe = y.IndexOf(" continue-on-error:", StringComparison.Ordinal);
|
||||
int iTm = y.IndexOf(" timeout-minutes:", StringComparison.Ordinal);
|
||||
int iEnv = y.IndexOf(" env:", StringComparison.Ordinal);
|
||||
int iWith = y.IndexOf(" with:", StringComparison.Ordinal);
|
||||
int iSrc = y.IndexOf(" source:", StringComparison.Ordinal);
|
||||
Assert.True(iId < iUses && iUses < iIf && iIf < iCoe && iCoe < iTm && iTm < iEnv && iEnv < iWith && iWith < iSrc,
|
||||
$"order wrong: id={iId} uses={iUses} if={iIf} coe={iCoe} tm={iTm} env={iEnv} with={iWith} src={iSrc}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Render_OmitsEmptyOptionalFields()
|
||||
{
|
||||
var entry = new JobExecutionViewEntry(
|
||||
JobExecutionPhase.Main,
|
||||
"bare",
|
||||
uses: "a/b@v1");
|
||||
|
||||
var result = JobExecutionViewRenderer.Render("j", new[] { entry });
|
||||
Assert.DoesNotContain(" id:", result.Yaml);
|
||||
Assert.DoesNotContain(" if:", result.Yaml);
|
||||
Assert.DoesNotContain(" continue-on-error:", result.Yaml);
|
||||
Assert.DoesNotContain(" timeout-minutes:", result.Yaml);
|
||||
Assert.DoesNotContain(" env:", result.Yaml);
|
||||
Assert.DoesNotContain(" with:", result.Yaml);
|
||||
Assert.DoesNotContain(" shell:", result.Yaml);
|
||||
Assert.DoesNotContain(" working-directory:", result.Yaml);
|
||||
Assert.DoesNotContain(" source:", result.Yaml);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Render_HandlesEmptyEntries()
|
||||
{
|
||||
var result = JobExecutionViewRenderer.Render("j", new List<JobExecutionViewEntry>());
|
||||
|
||||
Assert.Empty(result.EntryStartLines);
|
||||
Assert.Contains(" - step: Setup job\n", result.Yaml);
|
||||
Assert.Contains(" - step: Complete job\n", result.Yaml);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Render_ReportsCompleteJobLineMatchingYaml()
|
||||
{
|
||||
// Empty entries — Cleanup still emitted.
|
||||
var emptyResult = JobExecutionViewRenderer.Render("j", new List<JobExecutionViewEntry>());
|
||||
AssertCompleteJobLineMatchesYaml(emptyResult);
|
||||
|
||||
// Non-empty entries across phases.
|
||||
var populatedResult = JobExecutionViewRenderer.Render("build", WorkedExampleEntries());
|
||||
AssertCompleteJobLineMatchesYaml(populatedResult);
|
||||
}
|
||||
|
||||
private static void AssertCompleteJobLineMatchesYaml(RenderResult result)
|
||||
{
|
||||
var lines = result.Yaml.Split('\n');
|
||||
int? actual = null;
|
||||
for (int i = 0; i < lines.Length; i++)
|
||||
{
|
||||
if (lines[i] == " - step: Complete job")
|
||||
{
|
||||
actual = i + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
Assert.NotNull(actual);
|
||||
Assert.Equal(actual.Value, result.CompleteJobLine);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Render_NoPerEntryPhaseField()
|
||||
{
|
||||
// The phase: <value> per-entry field is gone — the section
|
||||
// header is the phase indicator. Guard against accidental
|
||||
// regressions.
|
||||
var result = JobExecutionViewRenderer.Render("build", WorkedExampleEntries());
|
||||
Assert.DoesNotContain("phase:", result.Yaml);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Render_ThrowsOnNullJobId()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(
|
||||
() => JobExecutionViewRenderer.Render(null, new List<JobExecutionViewEntry>()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Render_ThrowsOnWhitespaceJobId()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(
|
||||
() => JobExecutionViewRenderer.Render(" ", new List<JobExecutionViewEntry>()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Render_ThrowsOnNullEntries()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(
|
||||
() => JobExecutionViewRenderer.Render("j", null));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
[InlineData(null, 1)]
|
||||
[InlineData("", 1)]
|
||||
[InlineData(" ", 1)]
|
||||
public void Entry_Constructor_RejectsBadDisplayName(string displayName, int sourceLine)
|
||||
{
|
||||
Assert.Throws<ArgumentException>(
|
||||
() => new JobExecutionViewEntry(JobExecutionPhase.Main, displayName, sourceLine: sourceLine));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Entry_Constructor_RejectsZeroLineWhenSourcePathSet()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(
|
||||
() => new JobExecutionViewEntry(
|
||||
JobExecutionPhase.Main,
|
||||
"ok",
|
||||
sourcePath: "ci.yml",
|
||||
sourceLine: 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Render_AlwaysUsesLfLineBreaks()
|
||||
{
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -141,6 +141,7 @@ 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
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using GitHub.DistributedTask.WebApi;
|
||||
using GitHub.Runner.Worker;
|
||||
using GitHub.Runner.Worker.Dap;
|
||||
using Moq;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
@@ -83,6 +84,7 @@ 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);
|
||||
@@ -175,5 +177,29 @@ 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
428
src/Test/L0/Worker/StepEntryTranslatorL0.cs
Normal file
428
src/Test/L0/Worker/StepEntryTranslatorL0.cs
Normal file
@@ -0,0 +1,428 @@
|
||||
using System;
|
||||
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()
|
||||
{
|
||||
// The runner stores run-step inputs under the keys defined in
|
||||
// PipelineConstants.ScriptStepInputs (camelCase), NOT their
|
||||
// kebab-case workflow-YAML spellings — see
|
||||
// ActionManifestManagerWrapper:244.
|
||||
var action = new ActionStep
|
||||
{
|
||||
Reference = new ScriptReference(),
|
||||
Inputs = Map(
|
||||
(PipelineConstants.ScriptStepInputs.Script, Str("npm test")),
|
||||
(PipelineConstants.ScriptStepInputs.Shell, Str("bash")),
|
||||
(PipelineConstants.ScriptStepInputs.WorkingDirectory, 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")),
|
||||
(PipelineConstants.ScriptStepInputs.Script, Str("leak")),
|
||||
(PipelineConstants.ScriptStepInputs.Shell, Str("leak")),
|
||||
(PipelineConstants.ScriptStepInputs.WorkingDirectory, 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(PipelineConstants.ScriptStepInputs.Script, entry.WithYaml);
|
||||
Assert.DoesNotContain(PipelineConstants.ScriptStepInputs.Shell, entry.WithYaml);
|
||||
Assert.DoesNotContain(PipelineConstants.ScriptStepInputs.WorkingDirectory, 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
191
src/Test/L0/Worker/TemplateTokenYamlAdapterL0.cs
Normal file
191
src/Test/L0/Worker/TemplateTokenYamlAdapterL0.cs
Normal file
@@ -0,0 +1,191 @@
|
||||
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()
|
||||
{
|
||||
// A StringToken constructed directly with the literal text
|
||||
// round-trips unchanged. (The workflow parser does NOT produce
|
||||
// a StringToken for this input — see
|
||||
// Serialize_ReversesFormatRewriteForCompositeExpression — but
|
||||
// direct StringToken construction must still preserve the
|
||||
// literal verbatim.)
|
||||
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_ReversesFormatRewriteForCompositeExpression()
|
||||
{
|
||||
// The workflow parser tokenizes a mixed scalar like
|
||||
// `${{ runner.os }}-primes` as a single BasicExpressionToken
|
||||
// whose internal expression is `format('{0}-primes', runner.os)`.
|
||||
// The adapter must surface the author-facing form, not the
|
||||
// parser's normalized rewrite.
|
||||
var token = Expr("format('{0}-primes', runner.os)");
|
||||
string yaml = TemplateTokenYamlAdapter.Serialize(token, 0);
|
||||
Assert.Contains("${{ runner.os }}-primes", yaml);
|
||||
Assert.DoesNotContain("format(", 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"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Serialize_AlwaysUsesLfLineBreaks()
|
||||
{
|
||||
// Regression: YamlDotNet's Emitter calls WriteLine, which on
|
||||
// Windows produces CRLF (the host's Environment.NewLine).
|
||||
// Serialize must force LF so the rendered view round-trips
|
||||
// regardless of platform.
|
||||
var map = new MappingToken(null, null, null);
|
||||
map.Add(Str("k1"), Str("v1"));
|
||||
map.Add(Str("k2"), Num(2));
|
||||
map.Add(Str("k3"), Bool(true));
|
||||
string yaml = TemplateTokenYamlAdapter.Serialize(map, indentSpaces: 2);
|
||||
Assert.DoesNotContain("\r", yaml);
|
||||
}
|
||||
}
|
||||
}
|
||||
119
src/Test/L0/Worker/YamlScalarFormatterL0.cs
Normal file
119
src/Test/L0/Worker/YamlScalarFormatterL0.cs
Normal 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("&")]
|
||||
[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));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user