mirror of
https://github.com/actions/runner.git
synced 2026-07-08 22:24:02 +08:00
Compare commits
4 Commits
dap-execut
...
fix/normal
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed0e5b75ee | ||
|
|
fffded93ac | ||
|
|
8307b8fe33 | ||
|
|
7585eb30aa |
@@ -4,7 +4,7 @@
|
|||||||
"features": {
|
"features": {
|
||||||
"ghcr.io/devcontainers/features/docker-in-docker:2": {},
|
"ghcr.io/devcontainers/features/docker-in-docker:2": {},
|
||||||
"ghcr.io/devcontainers/features/dotnet": {
|
"ghcr.io/devcontainers/features/dotnet": {
|
||||||
"version": "8.0.421"
|
"version": "8.0.420"
|
||||||
},
|
},
|
||||||
"ghcr.io/devcontainers/features/node:1": {
|
"ghcr.io/devcontainers/features/node:1": {
|
||||||
"version": "20"
|
"version": "20"
|
||||||
|
|||||||
@@ -25,11 +25,11 @@ The `installdependencies.sh` script should install all required dependencies on
|
|||||||
|
|
||||||
Debian based OS (Debian, Ubuntu, Linux Mint)
|
Debian based OS (Debian, Ubuntu, Linux Mint)
|
||||||
|
|
||||||
- liblttng-ust1t64, liblttng-ust1 or liblttng-ust0
|
- liblttng-ust1 or liblttng-ust0
|
||||||
- libkrb5-3
|
- libkrb5-3
|
||||||
- zlib1g
|
- zlib1g
|
||||||
- libssl3t64, libssl3, libssl1.1, libssl1.0.2 or libssl1.0.0
|
- libssl3t64, libssl3, libssl1.1, libssl1.0.2 or libssl1.0.0
|
||||||
- libicu80, libicu79, ..., libicu66, libicu65, libicu63, libicu60, libicu57, libicu55, or libicu52
|
- libicu76, libicu75, ..., libicu66, libicu65, libicu63, libicu60, libicu57, libicu55, or libicu52
|
||||||
|
|
||||||
Fedora based OS (Fedora, Red Hat Enterprise Linux, CentOS, Oracle Linux 7)
|
Fedora based OS (Fedora, Red Hat Enterprise Linux, CentOS, Oracle Linux 7)
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ ARG TARGETOS
|
|||||||
ARG TARGETARCH
|
ARG TARGETARCH
|
||||||
ARG RUNNER_VERSION
|
ARG RUNNER_VERSION
|
||||||
ARG RUNNER_CONTAINER_HOOKS_VERSION=0.7.0
|
ARG RUNNER_CONTAINER_HOOKS_VERSION=0.7.0
|
||||||
ARG DOCKER_VERSION=29.5.0
|
ARG DOCKER_VERSION=29.4.0
|
||||||
ARG BUILDX_VERSION=0.34.0
|
ARG BUILDX_VERSION=0.33.0
|
||||||
|
|
||||||
RUN apt update -y && apt install curl unzip -y
|
RUN apt update -y && apt install curl unzip -y
|
||||||
|
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ then
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
apt_get_with_fallbacks liblttng-ust1t64 liblttng-ust1 liblttng-ust0
|
apt_get_with_fallbacks liblttng-ust1 liblttng-ust0
|
||||||
if [ $? -ne 0 ]
|
if [ $? -ne 0 ]
|
||||||
then
|
then
|
||||||
echo "'$apt_get' failed with exit code '$?'"
|
echo "'$apt_get' failed with exit code '$?'"
|
||||||
@@ -110,7 +110,7 @@ then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
apt_get_with_fallbacks libicu80 libicu79 libicu78 libicu77 libicu76 libicu75 libicu74 libicu73 libicu72 libicu71 libicu70 libicu69 libicu68 libicu67 libicu66 libicu65 libicu63 libicu60 libicu57 libicu55 libicu52
|
apt_get_with_fallbacks libicu76 libicu75 libicu74 libicu73 libicu72 libicu71 libicu70 libicu69 libicu68 libicu67 libicu66 libicu65 libicu63 libicu60 libicu57 libicu55 libicu52
|
||||||
if [ $? -ne 0 ]
|
if [ $? -ne 0 ]
|
||||||
then
|
then
|
||||||
echo "'$apt_get' failed with exit code '$?'"
|
echo "'$apt_get' failed with exit code '$?'"
|
||||||
|
|||||||
@@ -179,7 +179,6 @@ namespace GitHub.Runner.Common
|
|||||||
public static readonly string EmitCompositeMarkers = "actions_runner_emit_composite_markers";
|
public static readonly string EmitCompositeMarkers = "actions_runner_emit_composite_markers";
|
||||||
public static readonly string BatchActionResolution = "actions_batch_action_resolution";
|
public static readonly string BatchActionResolution = "actions_batch_action_resolution";
|
||||||
public static readonly string UseBearerTokenForCodeload = "actions_use_bearer_token_for_codeload";
|
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
|
// Node version migration related constants
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ namespace GitHub.Runner.Common
|
|||||||
private readonly List<ProductInfoHeaderValue> _userAgents = new() { new ProductInfoHeaderValue($"GitHubActionsRunner-{BuildConstants.RunnerPackage.PackageName}", BuildConstants.RunnerPackage.Version) };
|
private readonly List<ProductInfoHeaderValue> _userAgents = new() { new ProductInfoHeaderValue($"GitHubActionsRunner-{BuildConstants.RunnerPackage.PackageName}", BuildConstants.RunnerPackage.Version) };
|
||||||
private CancellationTokenSource _runnerShutdownTokenSource = new();
|
private CancellationTokenSource _runnerShutdownTokenSource = new();
|
||||||
private object _perfLock = new();
|
private object _perfLock = new();
|
||||||
|
private string _canonicalRootDirectory;
|
||||||
private Tracing _trace;
|
private Tracing _trace;
|
||||||
private Tracing _actionsHttpTrace;
|
private Tracing _actionsHttpTrace;
|
||||||
private Tracing _netcoreHttpTrace;
|
private Tracing _netcoreHttpTrace;
|
||||||
@@ -391,7 +392,12 @@ namespace GitHub.Runner.Common
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case WellKnownDirectory.Root:
|
case WellKnownDirectory.Root:
|
||||||
path = new DirectoryInfo(GetDirectory(WellKnownDirectory.Bin)).Parent.FullName;
|
if (_canonicalRootDirectory == null)
|
||||||
|
{
|
||||||
|
_canonicalRootDirectory = PathUtil.GetCanonicalPath(
|
||||||
|
new DirectoryInfo(GetDirectory(WellKnownDirectory.Bin)).Parent.FullName);
|
||||||
|
}
|
||||||
|
path = _canonicalRootDirectory;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case WellKnownDirectory.Temp:
|
case WellKnownDirectory.Temp:
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
using System.IO;
|
using System.IO;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Text;
|
||||||
|
using Microsoft.Win32.SafeHandles;
|
||||||
|
|
||||||
namespace GitHub.Runner.Sdk
|
namespace GitHub.Runner.Sdk
|
||||||
{
|
{
|
||||||
@@ -6,8 +9,98 @@ namespace GitHub.Runner.Sdk
|
|||||||
{
|
{
|
||||||
#if OS_WINDOWS
|
#if OS_WINDOWS
|
||||||
public static readonly string PathVariable = "Path";
|
public static readonly string PathVariable = "Path";
|
||||||
|
|
||||||
|
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||||
|
private static extern SafeFileHandle CreateFile(
|
||||||
|
string lpFileName,
|
||||||
|
uint dwDesiredAccess,
|
||||||
|
uint dwShareMode,
|
||||||
|
System.IntPtr lpSecurityAttributes,
|
||||||
|
uint dwCreationDisposition,
|
||||||
|
uint dwFlagsAndAttributes,
|
||||||
|
System.IntPtr hTemplateFile);
|
||||||
|
|
||||||
|
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||||
|
private static extern uint GetFinalPathNameByHandle(
|
||||||
|
SafeFileHandle hFile,
|
||||||
|
[Out] StringBuilder lpszFilePath,
|
||||||
|
uint cchFilePath,
|
||||||
|
uint dwFlags);
|
||||||
|
|
||||||
|
private const uint FILE_READ_ATTRIBUTES = 0x80;
|
||||||
|
private const uint FILE_SHARE_READ = 0x1;
|
||||||
|
private const uint FILE_SHARE_WRITE = 0x2;
|
||||||
|
private const uint FILE_SHARE_DELETE = 0x4;
|
||||||
|
private const uint OPEN_EXISTING = 3;
|
||||||
|
private const uint FILE_FLAG_BACKUP_SEMANTICS = 0x02000000;
|
||||||
|
private const uint VOLUME_NAME_DOS = 0x0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the NTFS canonical path for a directory, resolving drive letter
|
||||||
|
/// and folder name casing to match what is stored on disk.
|
||||||
|
/// On non-Windows platforms, returns the path unchanged.
|
||||||
|
/// </summary>
|
||||||
|
public static string GetCanonicalPath(string path)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(path) || !Directory.Exists(path))
|
||||||
|
{
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var handle = CreateFile(
|
||||||
|
path,
|
||||||
|
FILE_READ_ATTRIBUTES,
|
||||||
|
FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
|
||||||
|
System.IntPtr.Zero,
|
||||||
|
OPEN_EXISTING,
|
||||||
|
FILE_FLAG_BACKUP_SEMANTICS,
|
||||||
|
System.IntPtr.Zero);
|
||||||
|
|
||||||
|
if (handle.IsInvalid)
|
||||||
|
{
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
var buffer = new StringBuilder(1024);
|
||||||
|
var result = GetFinalPathNameByHandle(handle, buffer, (uint)buffer.Capacity, VOLUME_NAME_DOS);
|
||||||
|
if (result == 0)
|
||||||
|
{
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retry with a larger buffer if the path was longer than expected
|
||||||
|
if (result >= buffer.Capacity)
|
||||||
|
{
|
||||||
|
buffer = new StringBuilder((int)result + 1);
|
||||||
|
result = GetFinalPathNameByHandle(handle, buffer, (uint)buffer.Capacity, VOLUME_NAME_DOS);
|
||||||
|
if (result == 0 || result >= buffer.Capacity)
|
||||||
|
{
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var canonicalPath = buffer.ToString();
|
||||||
|
|
||||||
|
// Strip the \\?\UNC\ prefix and convert to standard UNC path
|
||||||
|
if (canonicalPath.StartsWith(@"\\?\UNC\", System.StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
canonicalPath = @"\\" + canonicalPath.Substring(8);
|
||||||
|
}
|
||||||
|
// Strip the \\?\ prefix for local paths
|
||||||
|
else if (canonicalPath.StartsWith(@"\\?\", System.StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
canonicalPath = canonicalPath.Substring(4);
|
||||||
|
}
|
||||||
|
|
||||||
|
return canonicalPath;
|
||||||
|
}
|
||||||
#else
|
#else
|
||||||
public static readonly string PathVariable = "PATH";
|
public static readonly string PathVariable = "PATH";
|
||||||
|
|
||||||
|
public static string GetCanonicalPath(string path)
|
||||||
|
{
|
||||||
|
return path;
|
||||||
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
public static string PrependPath(string path, string currentPath)
|
public static string PrependPath(string path, string currentPath)
|
||||||
|
|||||||
@@ -63,7 +63,6 @@ namespace GitHub.Runner.Worker.Dap
|
|||||||
private volatile DapSessionState _state = DapSessionState.NotStarted;
|
private volatile DapSessionState _state = DapSessionState.NotStarted;
|
||||||
private CancellationTokenRegistration? _cancellationRegistration;
|
private CancellationTokenRegistration? _cancellationRegistration;
|
||||||
private bool _isFirstStep = true;
|
private bool _isFirstStep = true;
|
||||||
private bool _welcomeMessageSent;
|
|
||||||
|
|
||||||
// Dev Tunnel relay host for remote debugging
|
// Dev Tunnel relay host for remote debugging
|
||||||
private TunnelRelayTunnelHost _tunnelRelayHost;
|
private TunnelRelayTunnelHost _tunnelRelayHost;
|
||||||
@@ -491,11 +490,6 @@ namespace GitHub.Runner.Worker.Dap
|
|||||||
});
|
});
|
||||||
Trace.Info("Sent initialized event");
|
Trace.Info("Sent initialized event");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (request.Command == "configurationDone")
|
|
||||||
{
|
|
||||||
SendWelcomeMessage();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -514,7 +508,6 @@ namespace GitHub.Runner.Worker.Dap
|
|||||||
internal void HandleClientConnected()
|
internal void HandleClientConnected()
|
||||||
{
|
{
|
||||||
_isClientConnected = true;
|
_isClientConnected = true;
|
||||||
_welcomeMessageSent = false;
|
|
||||||
Trace.Info("Client connected to debug session");
|
Trace.Info("Client connected to debug session");
|
||||||
|
|
||||||
// If we're paused, re-send the stopped event so the new client
|
// If we're paused, re-send the stopped event so the new client
|
||||||
@@ -825,34 +818,6 @@ 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)
|
internal async Task OnStepStartingAsync(IStep step, bool isFirstStep)
|
||||||
{
|
{
|
||||||
bool pauseOnNextStep;
|
bool pauseOnNextStep;
|
||||||
@@ -895,9 +860,6 @@ namespace GitHub.Runner.Worker.Dap
|
|||||||
// Send stopped event to debugger (only if client is connected)
|
// Send stopped event to debugger (only if client is connected)
|
||||||
SendStoppedEvent(reason, description);
|
SendStoppedEvent(reason, description);
|
||||||
|
|
||||||
// Emit a banner so the user knows where REPL commands will execute
|
|
||||||
SendExecutionContextBanner();
|
|
||||||
|
|
||||||
// Wait for debugger command
|
// Wait for debugger command
|
||||||
await WaitForCommandAsync(cancellationToken);
|
await WaitForCommandAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
@@ -1233,12 +1195,7 @@ namespace GitHub.Runner.Worker.Dap
|
|||||||
|
|
||||||
case RunCommand run:
|
case RunCommand run:
|
||||||
var context = GetExecutionContextForFrame(frameId);
|
var context = GetExecutionContextForFrame(frameId);
|
||||||
bool isActionStep;
|
return await _replExecutor.ExecuteRunCommandAsync(run, context, cancellationToken);
|
||||||
lock (_stateLock)
|
|
||||||
{
|
|
||||||
isActionStep = _currentStep is IActionRunner;
|
|
||||||
}
|
|
||||||
return await _replExecutor.ExecuteRunCommandAsync(run, context, isActionStep, cancellationToken);
|
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return new EvaluateResponseBody
|
return new EvaluateResponseBody
|
||||||
@@ -1450,40 +1407,6 @@ 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");
|
|
||||||
}
|
|
||||||
|
|
||||||
private string MaskUserVisibleText(string value)
|
private string MaskUserVisibleText(string value)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(value))
|
if (string.IsNullOrEmpty(value))
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ using GitHub.DistributedTask.Pipelines.ContextData;
|
|||||||
using GitHub.Runner.Common;
|
using GitHub.Runner.Common;
|
||||||
using GitHub.Runner.Common.Util;
|
using GitHub.Runner.Common.Util;
|
||||||
using GitHub.Runner.Sdk;
|
using GitHub.Runner.Sdk;
|
||||||
using GitHub.Runner.Worker.Container;
|
|
||||||
using GitHub.Runner.Worker.Handlers;
|
using GitHub.Runner.Worker.Handlers;
|
||||||
|
|
||||||
namespace GitHub.Runner.Worker.Dap
|
namespace GitHub.Runner.Worker.Dap
|
||||||
@@ -44,7 +43,6 @@ namespace GitHub.Runner.Worker.Dap
|
|||||||
public async Task<EvaluateResponseBody> ExecuteRunCommandAsync(
|
public async Task<EvaluateResponseBody> ExecuteRunCommandAsync(
|
||||||
RunCommand command,
|
RunCommand command,
|
||||||
IExecutionContext context,
|
IExecutionContext context,
|
||||||
bool isActionStep,
|
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (context == null)
|
if (context == null)
|
||||||
@@ -54,7 +52,7 @@ namespace GitHub.Runner.Worker.Dap
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
return await ExecuteScriptAsync(command, context, isActionStep, cancellationToken);
|
return await ExecuteScriptAsync(command, context, cancellationToken);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -67,17 +65,9 @@ namespace GitHub.Runner.Worker.Dap
|
|||||||
private async Task<EvaluateResponseBody> ExecuteScriptAsync(
|
private async Task<EvaluateResponseBody> ExecuteScriptAsync(
|
||||||
RunCommand command,
|
RunCommand command,
|
||||||
IExecutionContext context,
|
IExecutionContext context,
|
||||||
bool isActionStep,
|
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
// 1. Resolve step host — container or host, same as ActionRunner.
|
// 1. Resolve shell — same logic as ScriptHandler
|
||||||
// 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 shellCommand;
|
||||||
string argFormat;
|
string argFormat;
|
||||||
|
|
||||||
@@ -97,9 +87,9 @@ namespace GitHub.Runner.Worker.Dap
|
|||||||
argFormat = ScriptHandlerHelpers.GetScriptArgumentsFormat(shellCommand);
|
argFormat = ScriptHandlerHelpers.GetScriptArgumentsFormat(shellCommand);
|
||||||
}
|
}
|
||||||
|
|
||||||
_trace.Info($"Resolved REPL shell (container={isContainerStepHost})");
|
_trace.Info("Resolved REPL shell");
|
||||||
|
|
||||||
// 3. Expand ${{ }} expressions in the script body, just like
|
// 2. Expand ${{ }} expressions in the script body, just like
|
||||||
// ActionRunner evaluates step inputs before ScriptHandler sees them
|
// ActionRunner evaluates step inputs before ScriptHandler sees them
|
||||||
var contents = ExpandExpressions(command.Script, context);
|
var contents = ExpandExpressions(command.Script, context);
|
||||||
contents = ScriptHandlerHelpers.FixUpScriptContents(shellCommand, contents);
|
contents = ScriptHandlerHelpers.FixUpScriptContents(shellCommand, contents);
|
||||||
@@ -121,47 +111,25 @@ namespace GitHub.Runner.Worker.Dap
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// 4. Resolve script path — translate for container if needed
|
// 3. Format arguments with script path
|
||||||
var resolvedPath = stepHost.ResolvePathForStepHost(context, scriptFilePath).Replace("\"", "\\\"");
|
var resolvedPath = scriptFilePath.Replace("\"", "\\\"");
|
||||||
if (string.IsNullOrEmpty(argFormat) || !argFormat.Contains("{0}"))
|
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}}'");
|
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);
|
var arguments = string.Format(argFormat, resolvedPath);
|
||||||
|
|
||||||
// 5. Resolve shell command path — for containers, use the shell
|
// 4. Resolve shell command path
|
||||||
// name directly (it will be resolved inside the container);
|
|
||||||
// for host execution, resolve the full path on the host.
|
|
||||||
string prependPath = string.Join(
|
string prependPath = string.Join(
|
||||||
Path.PathSeparator.ToString(),
|
Path.PathSeparator.ToString(),
|
||||||
Enumerable.Reverse(context.Global.PrependPath));
|
Enumerable.Reverse(context.Global.PrependPath));
|
||||||
var fileName = isContainerStepHost
|
var commandPath = WhichUtil.Which(shellCommand, false, _trace, prependPath)
|
||||||
? shellCommand
|
?? shellCommand;
|
||||||
: WhichUtil.Which(shellCommand, false, _trace, prependPath) ?? shellCommand;
|
|
||||||
|
|
||||||
// 6. Build environment — merge from execution context like a real step
|
// 5. Build environment — merge from execution context like a real step
|
||||||
var environment = BuildEnvironment(context, command.Env);
|
var environment = BuildEnvironment(context, command.Env);
|
||||||
|
|
||||||
// 7. Handle PrependPath — mirrors Handler.AddPrependPathToEnvironment
|
// 6. Resolve working directory
|
||||||
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;
|
var workingDirectory = command.WorkingDirectory;
|
||||||
if (string.IsNullOrEmpty(workingDirectory))
|
if (string.IsNullOrEmpty(workingDirectory))
|
||||||
{
|
{
|
||||||
@@ -173,60 +141,48 @@ namespace GitHub.Runner.Worker.Dap
|
|||||||
: null;
|
: null;
|
||||||
workingDirectory = workspace ?? _hostContext.GetDirectory(WellKnownDirectory.Work);
|
workingDirectory = workspace ?? _hostContext.GetDirectory(WellKnownDirectory.Work);
|
||||||
}
|
}
|
||||||
workingDirectory = stepHost.ResolvePathForStepHost(context, workingDirectory);
|
|
||||||
|
|
||||||
_trace.Info("Executing REPL command");
|
_trace.Info("Executing REPL command");
|
||||||
|
|
||||||
// Stream execution info to debugger
|
// Stream execution info to debugger
|
||||||
SendOutput("console", $"$ {shellCommand} {command.Script.Substring(0, Math.Min(command.Script.Length, 80))}{(command.Script.Length > 80 ? "..." : "")}\n");
|
SendOutput("console", $"$ {shellCommand} {command.Script.Substring(0, Math.Min(command.Script.Length, 80))}{(command.Script.Length > 80 ? "..." : "")}\n");
|
||||||
|
|
||||||
// NOTE: When container hooks are enabled, ContainerStepHost routes
|
// 7. Execute via IProcessInvoker (same as DefaultStepHost)
|
||||||
// execution through IContainerHookManager which does not raise
|
int exitCode;
|
||||||
// OutputDataReceived/ErrorDataReceived events. Output will not be
|
using (var processInvoker = _hostContext.CreateService<IProcessInvoker>())
|
||||||
// streamed to the debug console in that mode.
|
|
||||||
if (isContainerStepHost && FeatureManager.IsContainerHooksEnabled(context.Global?.Variables))
|
|
||||||
{
|
{
|
||||||
const string hookWarning = "Container hooks are enabled. REPL output will not be streamed to the debug console for this command.";
|
processInvoker.OutputDataReceived += (sender, args) =>
|
||||||
_trace.Warning(hookWarning);
|
{
|
||||||
SendOutput("stderr", hookWarning + "\n");
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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}");
|
_trace.Info($"REPL command exited with code {exitCode}");
|
||||||
|
|
||||||
// 10. Return only the exit code summary (output was already streamed)
|
// 8. Return only the exit code summary (output was already streamed)
|
||||||
return new EvaluateResponseBody
|
return new EvaluateResponseBody
|
||||||
{
|
{
|
||||||
Result = exitCode == 0 ? $"(exit code: {exitCode})" : $"Process completed with exit code {exitCode}.",
|
Result = exitCode == 0 ? $"(exit code: {exitCode})" : $"Process completed with exit code {exitCode}.",
|
||||||
@@ -242,43 +198,6 @@ 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>
|
/// <summary>
|
||||||
/// Expands <c>${{ }}</c> expressions in the input string using the
|
/// Expands <c>${{ }}</c> expressions in the input string using the
|
||||||
/// runner's template evaluator — the same evaluation path that processes
|
/// 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
|
namespace GitHub.Runner.Worker.Dap
|
||||||
{
|
{
|
||||||
@@ -8,12 +8,10 @@ namespace GitHub.Runner.Worker.Dap
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class DebuggerConfig
|
public sealed class DebuggerConfig
|
||||||
{
|
{
|
||||||
public DebuggerConfig(bool enabled, DebuggerTunnelInfo tunnel, bool overrideWelcomeMessage = false, string welcomeMessage = null)
|
public DebuggerConfig(bool enabled, DebuggerTunnelInfo tunnel)
|
||||||
{
|
{
|
||||||
Enabled = enabled;
|
Enabled = enabled;
|
||||||
Tunnel = tunnel;
|
Tunnel = tunnel;
|
||||||
OverrideWelcomeMessage = overrideWelcomeMessage;
|
|
||||||
WelcomeMessage = welcomeMessage;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Whether the debugger is enabled for this job.</summary>
|
/// <summary>Whether the debugger is enabled for this job.</summary>
|
||||||
@@ -25,19 +23,6 @@ namespace GitHub.Runner.Worker.Dap
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public DebuggerTunnelInfo Tunnel { get; }
|
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>
|
/// <summary>Whether the tunnel configuration is complete and valid.</summary>
|
||||||
public bool HasValidTunnel => Tunnel != null
|
public bool HasValidTunnel => Tunnel != null
|
||||||
&& !string.IsNullOrEmpty(Tunnel.TunnelId)
|
&& !string.IsNullOrEmpty(Tunnel.TunnelId)
|
||||||
|
|||||||
@@ -1,276 +0,0 @@
|
|||||||
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>();
|
|
||||||
|
|
||||||
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>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;
|
|
||||||
|
|
||||||
_lineByStep.Clear();
|
|
||||||
for (int i = 0; i < _stepIdentities.Count; i++)
|
|
||||||
{
|
|
||||||
var step = _stepIdentities[i];
|
|
||||||
if (step != null)
|
|
||||||
{
|
|
||||||
_lineByStep[step] = _entryStartLines[i];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,336 +0,0 @@
|
|||||||
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)
|
|
||||||
{
|
|
||||||
Yaml = yaml;
|
|
||||||
EntryStartLines = entryStartLines;
|
|
||||||
}
|
|
||||||
|
|
||||||
public string Yaml { get; }
|
|
||||||
public IReadOnlyList<int> EntryStartLines { 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");
|
|
||||||
sb.Append(" - step: Complete job\n");
|
|
||||||
|
|
||||||
return new RenderResult(sb.ToString(), Array.AsReadOnly(startLines));
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -970,8 +970,7 @@ namespace GitHub.Runner.Worker
|
|||||||
Global.WriteDebug = Global.Variables.Step_Debug ?? false;
|
Global.WriteDebug = Global.Variables.Step_Debug ?? false;
|
||||||
|
|
||||||
// Debugger enabled flag (from acquire response).
|
// Debugger enabled flag (from acquire response).
|
||||||
var overrideDebuggerWelcomeMessage = Global.Variables.GetBoolean(Constants.Runner.Features.OverrideDebuggerWelcomeMessage) ?? false;
|
Global.Debugger = new Dap.DebuggerConfig(message.EnableDebugger, message.DebuggerTunnel);
|
||||||
Global.Debugger = new Dap.DebuggerConfig(message.EnableDebugger, message.DebuggerTunnel, overrideDebuggerWelcomeMessage, message.DebuggerWelcomeMessage);
|
|
||||||
|
|
||||||
// Hook up JobServerQueueThrottling event, we will log warning on server tarpit.
|
// Hook up JobServerQueueThrottling event, we will log warning on server tarpit.
|
||||||
_jobServerQueue.JobServerQueueThrottling += JobServerQueueThrottling_EventReceived;
|
_jobServerQueue.JobServerQueueThrottling += JobServerQueueThrottling_EventReceived;
|
||||||
|
|||||||
@@ -267,21 +267,6 @@ namespace GitHub.DistributedTask.Pipelines
|
|||||||
set;
|
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>
|
/// <summary>
|
||||||
/// Gets the workflow-level action dependencies (lockfile entries)
|
/// Gets the workflow-level action dependencies (lockfile entries)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -299,6 +299,52 @@ namespace GitHub.Runner.Common.Tests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Common")]
|
||||||
|
public void GetDirectoryRootReturnsCachedValue()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Setup();
|
||||||
|
|
||||||
|
// Call GetDirectory(Root) twice — should return the same reference
|
||||||
|
var root1 = _hc.GetDirectory(WellKnownDirectory.Root);
|
||||||
|
var root2 = _hc.GetDirectory(WellKnownDirectory.Root);
|
||||||
|
|
||||||
|
Assert.NotNull(root1);
|
||||||
|
Assert.Equal(root1, root2);
|
||||||
|
Assert.True(Directory.Exists(root1));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Teardown();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Common")]
|
||||||
|
public void GetDirectoryDerivedPathsUseRootCasing()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Setup();
|
||||||
|
|
||||||
|
var root = _hc.GetDirectory(WellKnownDirectory.Root);
|
||||||
|
var diag = _hc.GetDirectory(WellKnownDirectory.Diag);
|
||||||
|
var externals = _hc.GetDirectory(WellKnownDirectory.Externals);
|
||||||
|
|
||||||
|
// Diag and Externals should start with the same Root prefix
|
||||||
|
Assert.StartsWith(root, diag);
|
||||||
|
Assert.StartsWith(root, externals);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Teardown();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void Setup([CallerMemberName] string testName = "")
|
private void Setup([CallerMemberName] string testName = "")
|
||||||
{
|
{
|
||||||
_tokenSource = new CancellationTokenSource();
|
_tokenSource = new CancellationTokenSource();
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Runtime.Serialization.Json;
|
using System.Runtime.Serialization.Json;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
@@ -17,13 +17,13 @@ public sealed class AgentJobRequestMessageL0
|
|||||||
// Arrange
|
// Arrange
|
||||||
var serializer = new DataContractJsonSerializer(typeof(AgentJobRequestMessage));
|
var serializer = new DataContractJsonSerializer(typeof(AgentJobRequestMessage));
|
||||||
string jsonWithEnabledDebugger = DoubleQuotify("{'EnableDebugger': true}");
|
string jsonWithEnabledDebugger = DoubleQuotify("{'EnableDebugger': true}");
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
using var stream = new MemoryStream();
|
using var stream = new MemoryStream();
|
||||||
stream.Write(Encoding.UTF8.GetBytes(jsonWithEnabledDebugger));
|
stream.Write(Encoding.UTF8.GetBytes(jsonWithEnabledDebugger));
|
||||||
stream.Position = 0;
|
stream.Position = 0;
|
||||||
var recoveredMessage = serializer.ReadObject(stream) as AgentJobRequestMessage;
|
var recoveredMessage = serializer.ReadObject(stream) as AgentJobRequestMessage;
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
Assert.NotNull(recoveredMessage);
|
Assert.NotNull(recoveredMessage);
|
||||||
Assert.True(recoveredMessage.EnableDebugger, "EnableDebugger should be true when JSON contains 'EnableDebugger': true");
|
Assert.True(recoveredMessage.EnableDebugger, "EnableDebugger should be true when JSON contains 'EnableDebugger': true");
|
||||||
@@ -37,13 +37,13 @@ public sealed class AgentJobRequestMessageL0
|
|||||||
// Arrange
|
// Arrange
|
||||||
var serializer = new DataContractJsonSerializer(typeof(AgentJobRequestMessage));
|
var serializer = new DataContractJsonSerializer(typeof(AgentJobRequestMessage));
|
||||||
string jsonWithoutDebugger = DoubleQuotify("{'messageType': 'PipelineAgentJobRequest'}");
|
string jsonWithoutDebugger = DoubleQuotify("{'messageType': 'PipelineAgentJobRequest'}");
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
using var stream = new MemoryStream();
|
using var stream = new MemoryStream();
|
||||||
stream.Write(Encoding.UTF8.GetBytes(jsonWithoutDebugger));
|
stream.Write(Encoding.UTF8.GetBytes(jsonWithoutDebugger));
|
||||||
stream.Position = 0;
|
stream.Position = 0;
|
||||||
var recoveredMessage = serializer.ReadObject(stream) as AgentJobRequestMessage;
|
var recoveredMessage = serializer.ReadObject(stream) as AgentJobRequestMessage;
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
Assert.NotNull(recoveredMessage);
|
Assert.NotNull(recoveredMessage);
|
||||||
Assert.False(recoveredMessage.EnableDebugger, "EnableDebugger should default to false when JSON field is absent");
|
Assert.False(recoveredMessage.EnableDebugger, "EnableDebugger should default to false when JSON field is absent");
|
||||||
@@ -57,13 +57,13 @@ public sealed class AgentJobRequestMessageL0
|
|||||||
// Arrange
|
// Arrange
|
||||||
var serializer = new DataContractJsonSerializer(typeof(AgentJobRequestMessage));
|
var serializer = new DataContractJsonSerializer(typeof(AgentJobRequestMessage));
|
||||||
string jsonWithDisabledDebugger = DoubleQuotify("{'EnableDebugger': false}");
|
string jsonWithDisabledDebugger = DoubleQuotify("{'EnableDebugger': false}");
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
using var stream = new MemoryStream();
|
using var stream = new MemoryStream();
|
||||||
stream.Write(Encoding.UTF8.GetBytes(jsonWithDisabledDebugger));
|
stream.Write(Encoding.UTF8.GetBytes(jsonWithDisabledDebugger));
|
||||||
stream.Position = 0;
|
stream.Position = 0;
|
||||||
var recoveredMessage = serializer.ReadObject(stream) as AgentJobRequestMessage;
|
var recoveredMessage = serializer.ReadObject(stream) as AgentJobRequestMessage;
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
Assert.NotNull(recoveredMessage);
|
Assert.NotNull(recoveredMessage);
|
||||||
Assert.False(recoveredMessage.EnableDebugger, "EnableDebugger should be false when JSON contains 'EnableDebugger': false");
|
Assert.False(recoveredMessage.EnableDebugger, "EnableDebugger should be false when JSON contains 'EnableDebugger': false");
|
||||||
@@ -161,26 +161,6 @@ public sealed class AgentJobRequestMessageL0
|
|||||||
Assert.Empty(recoveredMessage.ActionsDependencies);
|
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)
|
private static string DoubleQuotify(string text)
|
||||||
{
|
{
|
||||||
return text.Replace('\'', '"');
|
return text.Replace('\'', '"');
|
||||||
|
|||||||
151
src/Test/L0/Util/PathUtilL0.cs
Normal file
151
src/Test/L0/Util/PathUtilL0.cs
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
using GitHub.Runner.Sdk;
|
||||||
|
using System.IO;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace GitHub.Runner.Common.Tests.Util
|
||||||
|
{
|
||||||
|
public sealed class PathUtilL0
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Common")]
|
||||||
|
public void GetCanonicalPath_ReturnsPath_WhenDirectoryDoesNotExist()
|
||||||
|
{
|
||||||
|
var fakePath = Path.Combine(Path.GetTempPath(), "nonexistent_" + Path.GetRandomFileName());
|
||||||
|
var result = PathUtil.GetCanonicalPath(fakePath);
|
||||||
|
Assert.Equal(fakePath, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Common")]
|
||||||
|
public void GetCanonicalPath_ReturnsPath_WhenNull()
|
||||||
|
{
|
||||||
|
Assert.Null(PathUtil.GetCanonicalPath(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Common")]
|
||||||
|
public void GetCanonicalPath_ReturnsEmpty_WhenEmpty()
|
||||||
|
{
|
||||||
|
Assert.Equal(string.Empty, PathUtil.GetCanonicalPath(string.Empty));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Common")]
|
||||||
|
public void GetCanonicalPath_ReturnsValidPath_ForExistingDirectory()
|
||||||
|
{
|
||||||
|
var tempDir = Path.Combine(Path.GetTempPath(), "pathutil_test_" + Path.GetRandomFileName());
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(tempDir);
|
||||||
|
var result = PathUtil.GetCanonicalPath(tempDir);
|
||||||
|
Assert.NotNull(result);
|
||||||
|
Assert.True(Directory.Exists(result));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (Directory.Exists(tempDir))
|
||||||
|
{
|
||||||
|
Directory.Delete(tempDir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if OS_WINDOWS
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Common")]
|
||||||
|
public void GetCanonicalPath_NormalizesDriveLetter_OnWindows()
|
||||||
|
{
|
||||||
|
var tempDir = Path.GetTempPath().TrimEnd(Path.DirectorySeparatorChar);
|
||||||
|
|
||||||
|
// Skip if temp is a UNC path (no drive letter to normalize)
|
||||||
|
if (tempDir.StartsWith(@"\\"))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Force lowercase drive letter
|
||||||
|
var lowerCased = char.ToLower(tempDir[0]) + tempDir.Substring(1);
|
||||||
|
|
||||||
|
var result = PathUtil.GetCanonicalPath(lowerCased);
|
||||||
|
|
||||||
|
// The canonical path should have an uppercase drive letter
|
||||||
|
Assert.True(char.IsUpper(result[0]),
|
||||||
|
$"Expected uppercase drive letter but got: {result}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Common")]
|
||||||
|
public void GetCanonicalPath_NormalizesFolderCasing_OnWindows()
|
||||||
|
{
|
||||||
|
// Create a directory with known casing, then query with wrong casing
|
||||||
|
var basePath = Path.GetTempPath();
|
||||||
|
if (basePath.StartsWith(@"\\"))
|
||||||
|
{
|
||||||
|
return; // Skip UNC
|
||||||
|
}
|
||||||
|
|
||||||
|
var realName = "PathUtilTest_MiXeDcAsE_" + Path.GetRandomFileName();
|
||||||
|
var realDir = Path.Combine(basePath, realName);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(realDir);
|
||||||
|
|
||||||
|
// Query with all-lowercase version
|
||||||
|
var wrongCased = Path.Combine(basePath, realName.ToLowerInvariant());
|
||||||
|
|
||||||
|
var result = PathUtil.GetCanonicalPath(wrongCased);
|
||||||
|
|
||||||
|
// The canonical result should contain the original mixed-case name
|
||||||
|
Assert.Contains(realName, result);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (Directory.Exists(realDir))
|
||||||
|
{
|
||||||
|
Directory.Delete(realDir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Common")]
|
||||||
|
public void GetCanonicalPath_IsIdempotent_OnWindows()
|
||||||
|
{
|
||||||
|
// Calling GetCanonicalPath twice should return the same result
|
||||||
|
var tempDir = Path.GetTempPath().TrimEnd(Path.DirectorySeparatorChar);
|
||||||
|
var first = PathUtil.GetCanonicalPath(tempDir);
|
||||||
|
var second = PathUtil.GetCanonicalPath(first);
|
||||||
|
Assert.Equal(first, second);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Level", "L0")]
|
||||||
|
[Trait("Category", "Common")]
|
||||||
|
public void GetCanonicalPath_ReturnsSameResult_RegardlessOfInputCasing_OnWindows()
|
||||||
|
{
|
||||||
|
var tempDir = Path.GetTempPath().TrimEnd(Path.DirectorySeparatorChar);
|
||||||
|
if (tempDir.StartsWith(@"\\"))
|
||||||
|
{
|
||||||
|
return; // Skip UNC
|
||||||
|
}
|
||||||
|
|
||||||
|
var upper = tempDir.ToUpperInvariant();
|
||||||
|
var lower = tempDir.ToLowerInvariant();
|
||||||
|
|
||||||
|
var resultUpper = PathUtil.GetCanonicalPath(upper);
|
||||||
|
var resultLower = PathUtil.GetCanonicalPath(lower);
|
||||||
|
|
||||||
|
// Both should resolve to the same canonical path
|
||||||
|
Assert.Equal(resultUpper, resultLower);
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Net.Sockets;
|
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, bool overrideWelcomeMessage = false, string welcomeMessage = null)
|
private static Mock<IExecutionContext> CreateJobContextWithTunnel(CancellationToken cancellationToken, ushort port, string jobName = null)
|
||||||
{
|
{
|
||||||
var tunnel = new GitHub.DistributedTask.Pipelines.DebuggerTunnelInfo
|
var tunnel = new GitHub.DistributedTask.Pipelines.DebuggerTunnelInfo
|
||||||
{
|
{
|
||||||
@@ -245,7 +245,7 @@ namespace GitHub.Runner.Common.Tests.Worker
|
|||||||
HostToken = "test-token",
|
HostToken = "test-token",
|
||||||
Port = port
|
Port = port
|
||||||
};
|
};
|
||||||
var debuggerConfig = new DebuggerConfig(true, tunnel, overrideWelcomeMessage, welcomeMessage);
|
var debuggerConfig = new DebuggerConfig(true, tunnel);
|
||||||
var jobContext = new Mock<IExecutionContext>();
|
var jobContext = new Mock<IExecutionContext>();
|
||||||
jobContext.Setup(x => x.CancellationToken).Returns(cancellationToken);
|
jobContext.Setup(x => x.CancellationToken).Returns(cancellationToken);
|
||||||
jobContext.Setup(x => x.Global).Returns(new GlobalContext { Debugger = debuggerConfig });
|
jobContext.Setup(x => x.Global).Returns(new GlobalContext { Debugger = debuggerConfig });
|
||||||
@@ -742,8 +742,6 @@ namespace GitHub.Runner.Common.Tests.Worker
|
|||||||
|
|
||||||
// Read the configurationDone response
|
// Read the configurationDone response
|
||||||
await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
|
await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
|
||||||
// Read the welcome message output event
|
|
||||||
await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
|
|
||||||
await waitTask;
|
await waitTask;
|
||||||
|
|
||||||
// Complete the job — OnJobCompletedAsync pauses when stepping,
|
// Complete the job — OnJobCompletedAsync pauses when stepping,
|
||||||
@@ -851,8 +849,6 @@ namespace GitHub.Runner.Common.Tests.Worker
|
|||||||
Command = "configurationDone"
|
Command = "configurationDone"
|
||||||
});
|
});
|
||||||
|
|
||||||
await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
|
|
||||||
// Read the welcome message output event
|
|
||||||
await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
|
await ReadDapMessageAsync(stream, TimeSpan.FromSeconds(5));
|
||||||
await waitTask;
|
await waitTask;
|
||||||
|
|
||||||
@@ -871,224 +867,5 @@ namespace GitHub.Runner.Common.Tests.Worker
|
|||||||
Assert.Equal(completedTask, finished);
|
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,9 @@ using System.Threading;
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using GitHub.DistributedTask.Expressions2;
|
using GitHub.DistributedTask.Expressions2;
|
||||||
using GitHub.DistributedTask.Pipelines.ContextData;
|
using GitHub.DistributedTask.Pipelines.ContextData;
|
||||||
using GitHub.DistributedTask.WebApi;
|
|
||||||
using GitHub.Runner.Common.Tests;
|
using GitHub.Runner.Common.Tests;
|
||||||
using GitHub.Runner.Worker;
|
using GitHub.Runner.Worker;
|
||||||
using GitHub.Runner.Worker.Container;
|
|
||||||
using GitHub.Runner.Worker.Dap;
|
using GitHub.Runner.Worker.Dap;
|
||||||
using GitHub.Runner.Worker.Handlers;
|
|
||||||
using Moq;
|
using Moq;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
@@ -43,8 +40,7 @@ namespace GitHub.Runner.Common.Tests.Worker
|
|||||||
|
|
||||||
private Mock<IExecutionContext> CreateMockContext(
|
private Mock<IExecutionContext> CreateMockContext(
|
||||||
DictionaryContextData exprValues = null,
|
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>();
|
var mock = new Mock<IExecutionContext>();
|
||||||
mock.Setup(x => x.ExpressionValues).Returns(exprValues ?? new DictionaryContextData());
|
mock.Setup(x => x.ExpressionValues).Returns(exprValues ?? new DictionaryContextData());
|
||||||
@@ -55,7 +51,6 @@ namespace GitHub.Runner.Common.Tests.Worker
|
|||||||
PrependPath = new List<string>(),
|
PrependPath = new List<string>(),
|
||||||
JobDefaults = jobDefaults
|
JobDefaults = jobDefaults
|
||||||
?? new Dictionary<string, IDictionary<string, string>>(StringComparer.OrdinalIgnoreCase),
|
?? new Dictionary<string, IDictionary<string, string>>(StringComparer.OrdinalIgnoreCase),
|
||||||
Container = container,
|
|
||||||
};
|
};
|
||||||
mock.Setup(x => x.Global).Returns(global);
|
mock.Setup(x => x.Global).Returns(global);
|
||||||
|
|
||||||
@@ -70,7 +65,7 @@ namespace GitHub.Runner.Common.Tests.Worker
|
|||||||
using (CreateTestContext())
|
using (CreateTestContext())
|
||||||
{
|
{
|
||||||
var command = new RunCommand { Script = "echo hello" };
|
var command = new RunCommand { Script = "echo hello" };
|
||||||
var result = await _executor.ExecuteRunCommandAsync(command, null, false, CancellationToken.None);
|
var result = await _executor.ExecuteRunCommandAsync(command, null, CancellationToken.None);
|
||||||
|
|
||||||
Assert.Equal("error", result.Type);
|
Assert.Equal("error", result.Type);
|
||||||
Assert.Contains("No execution context available", result.Result);
|
Assert.Contains("No execution context available", result.Result);
|
||||||
@@ -238,101 +233,5 @@ namespace GitHub.Runner.Common.Tests.Worker
|
|||||||
Assert.False(result.ContainsKey("BAZ"));
|
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,442 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,598 +0,0 @@
|
|||||||
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_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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -17,7 +17,7 @@ LAYOUT_DIR="$SCRIPT_DIR/../_layout"
|
|||||||
DOWNLOAD_DIR="$SCRIPT_DIR/../_downloads/netcore2x"
|
DOWNLOAD_DIR="$SCRIPT_DIR/../_downloads/netcore2x"
|
||||||
PACKAGE_DIR="$SCRIPT_DIR/../_package"
|
PACKAGE_DIR="$SCRIPT_DIR/../_package"
|
||||||
DOTNETSDK_ROOT="$SCRIPT_DIR/../_dotnetsdk"
|
DOTNETSDK_ROOT="$SCRIPT_DIR/../_dotnetsdk"
|
||||||
DOTNETSDK_VERSION="8.0.421"
|
DOTNETSDK_VERSION="8.0.420"
|
||||||
DOTNETSDK_INSTALLDIR="$DOTNETSDK_ROOT/$DOTNETSDK_VERSION"
|
DOTNETSDK_INSTALLDIR="$DOTNETSDK_ROOT/$DOTNETSDK_VERSION"
|
||||||
RUNNER_VERSION=$(cat runnerversion)
|
RUNNER_VERSION=$(cat runnerversion)
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"sdk": {
|
"sdk": {
|
||||||
"version": "8.0.421"
|
"version": "8.0.420"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user