mirror of
https://github.com/actions/runner.git
synced 2026-07-04 19:45:31 +08:00
Compare commits
2 Commits
dap-execut
...
dap-step-e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f41c09ca4 | ||
|
|
aa5aaea56a |
240
src/Runner.Worker/Dap/StepEntryTranslator.cs
Normal file
240
src/Runner.Worker/Dap/StepEntryTranslator.cs
Normal file
@@ -0,0 +1,240 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using GitHub.DistributedTask.ObjectTemplating.Tokens;
|
||||
using GitHub.DistributedTask.Pipelines;
|
||||
using GitHub.Runner.Sdk;
|
||||
|
||||
namespace GitHub.Runner.Worker.Dap
|
||||
{
|
||||
/// <summary>
|
||||
/// Translates runner <see cref="IStep"/> instances into pure-data
|
||||
/// <see cref="JobExecutionViewEntry"/> records used by the DAP debugger
|
||||
/// execution view. Filters out runner-internal steps (e.g.
|
||||
/// <see cref="JobExtensionRunner"/>) so the rendered view only shows
|
||||
/// user-visible workflow steps.
|
||||
/// </summary>
|
||||
internal static class StepEntryTranslator
|
||||
{
|
||||
// Run-step internals carried on ActionStep.Inputs that are NOT
|
||||
// user-authored `with:` entries. The runner stores these under
|
||||
// the keys defined in PipelineConstants.ScriptStepInputs, NOT
|
||||
// their kebab-case workflow-YAML spellings.
|
||||
private static readonly HashSet<string> RunStepInternalKeys = new(StringComparer.Ordinal)
|
||||
{
|
||||
PipelineConstants.ScriptStepInputs.Script,
|
||||
PipelineConstants.ScriptStepInputs.Shell,
|
||||
PipelineConstants.ScriptStepInputs.WorkingDirectory,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Translate an IStep into a JobExecutionViewEntry.
|
||||
/// </summary>
|
||||
/// <param name="step">The IStep to translate. Must not be null.</param>
|
||||
/// <returns>
|
||||
/// A JobExecutionViewEntry, or null if the step is not user-visible
|
||||
/// (JobExtensionRunner and any other non-IActionRunner IStep impls).
|
||||
/// </returns>
|
||||
public static JobExecutionViewEntry TryTranslate(IStep step)
|
||||
{
|
||||
ArgUtil.NotNull(step, nameof(step));
|
||||
|
||||
if (step is JobExtensionRunner)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (step is not IActionRunner actionRunner)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var phase = actionRunner.Stage switch
|
||||
{
|
||||
ActionRunStage.Pre => JobExecutionPhase.Pre,
|
||||
ActionRunStage.Post => JobExecutionPhase.Post,
|
||||
_ => JobExecutionPhase.Main,
|
||||
};
|
||||
|
||||
string displayName = actionRunner.DisplayName;
|
||||
if (string.IsNullOrWhiteSpace(displayName))
|
||||
{
|
||||
displayName = "run";
|
||||
}
|
||||
|
||||
string uses = null;
|
||||
string run = null;
|
||||
string id = null;
|
||||
string ifCond = null;
|
||||
string continueOnError = null;
|
||||
string timeoutMinutes = null;
|
||||
string envYaml = null;
|
||||
string withYaml = null;
|
||||
string shell = null;
|
||||
string workingDirectory = null;
|
||||
|
||||
var action = actionRunner.Action;
|
||||
var reference = action?.Reference;
|
||||
bool isScript = reference?.Type == ActionSourceType.Script;
|
||||
|
||||
if (reference != null && !isScript)
|
||||
{
|
||||
uses = FormatActionReference(reference);
|
||||
}
|
||||
|
||||
// Only the user-visible Main entry surfaces authored params.
|
||||
// Pre/Post stay minimal (step + action) — they reference the
|
||||
// same Action as the Main entry, and duplicating params adds
|
||||
// noise without information.
|
||||
if (phase == JobExecutionPhase.Main && action != null)
|
||||
{
|
||||
id = FilterAuthoredId(action.ContextName);
|
||||
|
||||
if (!string.IsNullOrEmpty(action.Condition))
|
||||
{
|
||||
ifCond = action.Condition;
|
||||
}
|
||||
|
||||
if (action.ContinueOnError != null)
|
||||
{
|
||||
continueOnError = TemplateTokenYamlAdapter.Serialize(action.ContinueOnError, indentSpaces: 0);
|
||||
}
|
||||
if (action.TimeoutInMinutes != null)
|
||||
{
|
||||
timeoutMinutes = TemplateTokenYamlAdapter.Serialize(action.TimeoutInMinutes, indentSpaces: 0);
|
||||
}
|
||||
|
||||
if (action.Environment is MappingToken envMap && envMap.Count > 0)
|
||||
{
|
||||
envYaml = TemplateTokenYamlAdapter.Serialize(envMap, indentSpaces: 6);
|
||||
}
|
||||
else if (action.Environment != null && !(action.Environment is MappingToken))
|
||||
{
|
||||
// Unusual but possible: env: ${{ ... }} expression form.
|
||||
envYaml = TemplateTokenYamlAdapter.Serialize(action.Environment, indentSpaces: 6);
|
||||
}
|
||||
|
||||
if (isScript)
|
||||
{
|
||||
var inputs = action.Inputs as MappingToken;
|
||||
if (inputs != null)
|
||||
{
|
||||
if (TryGetMapValue(inputs, PipelineConstants.ScriptStepInputs.Script, out var scriptTok) && scriptTok != null)
|
||||
{
|
||||
run = scriptTok.ToString();
|
||||
}
|
||||
if (TryGetMapValue(inputs, PipelineConstants.ScriptStepInputs.Shell, out var shellTok) && shellTok != null)
|
||||
{
|
||||
string shellText = shellTok.ToString();
|
||||
if (!string.IsNullOrEmpty(shellText))
|
||||
{
|
||||
shell = shellText;
|
||||
}
|
||||
}
|
||||
if (TryGetMapValue(inputs, PipelineConstants.ScriptStepInputs.WorkingDirectory, out var wdTok) && wdTok != null)
|
||||
{
|
||||
string wdText = wdTok.ToString();
|
||||
if (!string.IsNullOrEmpty(wdText))
|
||||
{
|
||||
workingDirectory = wdText;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Action step: surface `with:` entries, filtering any
|
||||
// run-step internal keys defensively.
|
||||
if (action.Inputs is MappingToken withMap && withMap.Count > 0)
|
||||
{
|
||||
var filtered = FilterMapping(withMap, RunStepInternalKeys);
|
||||
if (filtered != null && filtered.Count > 0)
|
||||
{
|
||||
withYaml = TemplateTokenYamlAdapter.Serialize(filtered, indentSpaces: 6);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Source annotation (SourcePath/SourceLine) requires a public
|
||||
// seam onto TemplateToken position info — not wired yet.
|
||||
return new JobExecutionViewEntry(
|
||||
phase: phase,
|
||||
displayName: displayName,
|
||||
uses: uses,
|
||||
run: run,
|
||||
sourcePath: null,
|
||||
sourceLine: 0,
|
||||
id: id,
|
||||
@if: ifCond,
|
||||
continueOnError: continueOnError,
|
||||
timeoutMinutes: timeoutMinutes,
|
||||
envYaml: envYaml,
|
||||
withYaml: withYaml,
|
||||
shell: shell,
|
||||
workingDirectory: workingDirectory);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Auto-generated step IDs are noise in the view: filter them out.
|
||||
/// The runner's convention (see ExecutionContext) is that auto-
|
||||
/// generated context names start with <c>__</c>. Only user-authored
|
||||
/// IDs survive the filter.
|
||||
/// </summary>
|
||||
internal static string FilterAuthoredId(string contextName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(contextName))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
if (contextName.StartsWith("__", StringComparison.Ordinal))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return contextName;
|
||||
}
|
||||
|
||||
private static bool TryGetMapValue(MappingToken map, string key, out TemplateToken value)
|
||||
{
|
||||
foreach (var pair in map)
|
||||
{
|
||||
if (pair.Key is StringToken s && string.Equals(s.Value, key, StringComparison.Ordinal))
|
||||
{
|
||||
value = pair.Value;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
value = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static MappingToken FilterMapping(MappingToken source, HashSet<string> excludeKeys)
|
||||
{
|
||||
var copy = new MappingToken(source.FileId, source.Line, source.Column);
|
||||
foreach (var pair in source)
|
||||
{
|
||||
if (pair.Key is StringToken sk && excludeKeys.Contains(sk.Value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
copy.Add(pair);
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
|
||||
internal static string FormatActionReference(ActionStepDefinitionReference reference)
|
||||
{
|
||||
switch (reference)
|
||||
{
|
||||
case RepositoryPathReference repo:
|
||||
var path = string.IsNullOrEmpty(repo.Path) ? string.Empty : $"/{repo.Path}";
|
||||
return string.IsNullOrEmpty(repo.Ref)
|
||||
? $"{repo.Name}{path}"
|
||||
: $"{repo.Name}{path}@{repo.Ref}";
|
||||
case ContainerRegistryReference container:
|
||||
return container.Image;
|
||||
default:
|
||||
return reference.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
223
src/Runner.Worker/Dap/TemplateTokenYamlAdapter.cs
Normal file
223
src/Runner.Worker/Dap/TemplateTokenYamlAdapter.cs
Normal file
@@ -0,0 +1,223 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using GitHub.DistributedTask.ObjectTemplating;
|
||||
using GitHub.DistributedTask.ObjectTemplating.Tokens;
|
||||
using GitHub.Runner.Sdk;
|
||||
using YamlDotNet.Core;
|
||||
using YamlDotNet.Core.Events;
|
||||
|
||||
namespace GitHub.Runner.Worker.Dap
|
||||
{
|
||||
/// <summary>
|
||||
/// Adapts a YamlDotNet <see cref="IEmitter"/> as a DT
|
||||
/// <see cref="IObjectWriter"/> so a <see cref="TemplateToken"/> DOM
|
||||
/// can be serialized back to YAML preserving its pre-evaluation form
|
||||
/// (basic <c>${{ }}</c> expressions are written through verbatim).
|
||||
///
|
||||
/// Used by the DAP execution view to surface user-authored step
|
||||
/// parameters (<c>env:</c>, <c>with:</c>, <c>run:</c>, ...) without
|
||||
/// any expression substitution.
|
||||
/// </summary>
|
||||
internal sealed class TemplateTokenYamlAdapter : IObjectWriter
|
||||
{
|
||||
private readonly IEmitter _emitter;
|
||||
|
||||
public TemplateTokenYamlAdapter(IEmitter emitter)
|
||||
{
|
||||
ArgUtil.NotNull(emitter, nameof(emitter));
|
||||
_emitter = emitter;
|
||||
}
|
||||
|
||||
public void WriteStart()
|
||||
{
|
||||
_emitter.Emit(new StreamStart());
|
||||
_emitter.Emit(new DocumentStart(null, null, true));
|
||||
}
|
||||
|
||||
public void WriteEnd()
|
||||
{
|
||||
_emitter.Emit(new DocumentEnd(true));
|
||||
_emitter.Emit(new StreamEnd());
|
||||
}
|
||||
|
||||
public void WriteNull() =>
|
||||
_emitter.Emit(new Scalar(null, null, "null", ScalarStyle.Plain, true, false));
|
||||
|
||||
public void WriteBoolean(bool value) =>
|
||||
_emitter.Emit(new Scalar(null, null, value ? "true" : "false", ScalarStyle.Plain, true, false));
|
||||
|
||||
public void WriteNumber(double value) =>
|
||||
_emitter.Emit(new Scalar(null, null, value.ToString("R", CultureInfo.InvariantCulture), ScalarStyle.Plain, true, false));
|
||||
|
||||
public void WriteString(string value)
|
||||
{
|
||||
if (value == null)
|
||||
{
|
||||
WriteNull();
|
||||
return;
|
||||
}
|
||||
// Multi-line strings render as block literal so embedded
|
||||
// newlines survive the YAML round trip.
|
||||
var style = value.IndexOf('\n') >= 0 ? ScalarStyle.Literal : ScalarStyle.Any;
|
||||
_emitter.Emit(new Scalar(null, null, value, style, true, true));
|
||||
}
|
||||
|
||||
public void WriteSequenceStart() =>
|
||||
_emitter.Emit(new SequenceStart(null, null, true, SequenceStyle.Any));
|
||||
|
||||
public void WriteSequenceEnd() =>
|
||||
_emitter.Emit(new SequenceEnd());
|
||||
|
||||
public void WriteMappingStart() =>
|
||||
_emitter.Emit(new MappingStart(null, null, true, MappingStyle.Any));
|
||||
|
||||
public void WriteMappingEnd() =>
|
||||
_emitter.Emit(new MappingEnd());
|
||||
|
||||
/// <summary>
|
||||
/// Serialize a TemplateToken to a YAML fragment ready to embed
|
||||
/// under a parent key. Each non-empty line is prefixed by
|
||||
/// <paramref name="indentSpaces"/> spaces. Trailing newlines and
|
||||
/// the YAML stream start/document markers are stripped, so the
|
||||
/// caller controls line breaks.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Empty mappings render as <c>{}</c> and empty sequences as
|
||||
/// <c>[]</c> via YamlDotNet's flow style fallback for empty
|
||||
/// collections.
|
||||
/// </remarks>
|
||||
internal static string Serialize(TemplateToken token, int indentSpaces)
|
||||
{
|
||||
if (indentSpaces < 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(indentSpaces));
|
||||
}
|
||||
|
||||
using var sw = new StringWriter(CultureInfo.InvariantCulture);
|
||||
// Force LF line breaks; YamlDotNet's Emitter calls WriteLine,
|
||||
// which would otherwise produce CRLF on Windows and corrupt
|
||||
// both the document-end stripping below and the per-line
|
||||
// indentation pass that follows.
|
||||
sw.NewLine = "\n";
|
||||
var emitter = new Emitter(sw);
|
||||
var adapter = new TemplateTokenYamlAdapter(emitter);
|
||||
adapter.WriteStart();
|
||||
WriteToken(adapter, token);
|
||||
adapter.WriteEnd();
|
||||
|
||||
string raw = sw.ToString();
|
||||
// Strip YAML document markers. The Emitter most commonly elides
|
||||
// these for our use (DocumentStart isImplicit=true), but emits
|
||||
// them for some scalar edge cases (e.g. empty strings) and may
|
||||
// emit them on their own line for collection roots under some
|
||||
// settings. Strip both shapes defensively so callers never see
|
||||
// a leaked marker leak into the embedded fragment.
|
||||
if (raw.StartsWith("--- ", StringComparison.Ordinal))
|
||||
{
|
||||
raw = raw.Substring(4);
|
||||
}
|
||||
else if (raw.StartsWith("---\n", StringComparison.Ordinal))
|
||||
{
|
||||
raw = raw.Substring(4);
|
||||
}
|
||||
const string DocEndMarker = "\n...";
|
||||
if (raw.EndsWith(DocEndMarker + "\n", StringComparison.Ordinal))
|
||||
{
|
||||
raw = raw.Substring(0, raw.Length - DocEndMarker.Length - 1);
|
||||
}
|
||||
else if (raw.EndsWith(DocEndMarker, StringComparison.Ordinal))
|
||||
{
|
||||
raw = raw.Substring(0, raw.Length - DocEndMarker.Length);
|
||||
}
|
||||
raw = raw.TrimEnd('\n');
|
||||
|
||||
if (indentSpaces == 0)
|
||||
{
|
||||
return raw;
|
||||
}
|
||||
|
||||
// Re-indent every non-empty line. Empty lines remain empty
|
||||
// so YAML block-literal blank lines stay valid.
|
||||
var pad = new string(' ', indentSpaces);
|
||||
var sb = new System.Text.StringBuilder(raw.Length + indentSpaces * 4);
|
||||
int i = 0;
|
||||
while (i < raw.Length)
|
||||
{
|
||||
int end = raw.IndexOf('\n', i);
|
||||
int lineEnd = end < 0 ? raw.Length : end;
|
||||
if (lineEnd > i)
|
||||
{
|
||||
sb.Append(pad);
|
||||
sb.Append(raw, i, lineEnd - i);
|
||||
}
|
||||
if (end < 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
sb.Append('\n');
|
||||
i = end + 1;
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mirrors <see cref="TemplateWriter"/>'s recursive walk, with one
|
||||
/// behavioural change: <see cref="BasicExpressionToken"/> is emitted
|
||||
/// via <c>ToDisplayString()</c> instead of <c>ToString()</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The workflow parser tokenizes a mixed scalar like
|
||||
/// <c>${{ runner.os }}-primes</c> as a single
|
||||
/// <see cref="BasicExpressionToken"/> whose internal expression is
|
||||
/// <c>format('{0}-primes', runner.os)</c>. <c>ToString()</c> emits
|
||||
/// the normalized form verbatim; <c>ToDisplayString()</c> reverses
|
||||
/// the <c>format(...)</c> rewrite so the user sees the original
|
||||
/// authored form. Other token kinds delegate to the same writer
|
||||
/// calls <see cref="TemplateWriter"/> would make.
|
||||
/// </remarks>
|
||||
private static void WriteToken(IObjectWriter writer, TemplateToken token)
|
||||
{
|
||||
switch (token?.Type ?? TokenType.Null)
|
||||
{
|
||||
case TokenType.Null:
|
||||
writer.WriteNull();
|
||||
break;
|
||||
case TokenType.Boolean:
|
||||
writer.WriteBoolean(((BooleanToken)token).Value);
|
||||
break;
|
||||
case TokenType.Number:
|
||||
writer.WriteNumber(((NumberToken)token).Value);
|
||||
break;
|
||||
case TokenType.String:
|
||||
writer.WriteString(token.ToString());
|
||||
break;
|
||||
case TokenType.BasicExpression:
|
||||
writer.WriteString(((BasicExpressionToken)token).ToDisplayString());
|
||||
break;
|
||||
case TokenType.InsertExpression:
|
||||
writer.WriteString(token.ToString());
|
||||
break;
|
||||
case TokenType.Mapping:
|
||||
writer.WriteMappingStart();
|
||||
foreach (var pair in (MappingToken)token)
|
||||
{
|
||||
WriteToken(writer, pair.Key);
|
||||
WriteToken(writer, pair.Value);
|
||||
}
|
||||
writer.WriteMappingEnd();
|
||||
break;
|
||||
case TokenType.Sequence:
|
||||
writer.WriteSequenceStart();
|
||||
foreach (var item in (SequenceToken)token)
|
||||
{
|
||||
WriteToken(writer, item);
|
||||
}
|
||||
writer.WriteSequenceEnd();
|
||||
break;
|
||||
default:
|
||||
throw new NotSupportedException($"Unexpected token type '{token.GetType()}'.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
428
src/Test/L0/Worker/StepEntryTranslatorL0.cs
Normal file
428
src/Test/L0/Worker/StepEntryTranslatorL0.cs
Normal file
@@ -0,0 +1,428 @@
|
||||
using System;
|
||||
using GitHub.DistributedTask.ObjectTemplating.Tokens;
|
||||
using GitHub.DistributedTask.Pipelines;
|
||||
using GitHub.Runner.Worker;
|
||||
using GitHub.Runner.Worker.Dap;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace GitHub.Runner.Common.Tests.Worker
|
||||
{
|
||||
public sealed class StepEntryTranslatorL0
|
||||
{
|
||||
private static StringToken Str(string s) => new(null, null, null, s);
|
||||
|
||||
private static MappingToken Map(params (string Key, TemplateToken Value)[] pairs)
|
||||
{
|
||||
var m = new MappingToken(null, null, null);
|
||||
foreach (var (k, v) in pairs)
|
||||
{
|
||||
m.Add(Str(k), v);
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
private static Mock<IActionRunner> NewActionRunnerMock(
|
||||
ActionRunStage stage,
|
||||
string displayName,
|
||||
ActionStepDefinitionReference reference,
|
||||
ActionStep actionOverride = null)
|
||||
{
|
||||
var mock = new Mock<IActionRunner>();
|
||||
mock.SetupGet(x => x.Stage).Returns(stage);
|
||||
mock.SetupGet(x => x.DisplayName).Returns(displayName);
|
||||
mock.SetupGet(x => x.Action).Returns(actionOverride ?? new ActionStep
|
||||
{
|
||||
Reference = reference,
|
||||
});
|
||||
return mock;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Translate_NullStep_Throws()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
StepEntryTranslator.TryTranslate(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Translate_JobExtensionRunner_ReturnsNull()
|
||||
{
|
||||
var step = new JobExtensionRunner(
|
||||
runAsync: (_, __) => System.Threading.Tasks.Task.CompletedTask,
|
||||
condition: null,
|
||||
displayName: "Set up job",
|
||||
data: null);
|
||||
|
||||
Assert.Null(StepEntryTranslator.TryTranslate(step));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Translate_OtherIStepType_ReturnsNull()
|
||||
{
|
||||
var mock = new Mock<IStep>();
|
||||
mock.SetupGet(x => x.DisplayName).Returns("custom");
|
||||
|
||||
Assert.Null(StepEntryTranslator.TryTranslate(mock.Object));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Translate_ActionRunnerPre_ReturnsPreEntry()
|
||||
{
|
||||
var reference = new RepositoryPathReference
|
||||
{
|
||||
Name = "actions/checkout",
|
||||
Ref = "v4",
|
||||
};
|
||||
var mock = NewActionRunnerMock(ActionRunStage.Pre, "Pre Run actions/checkout@v4", reference);
|
||||
|
||||
var entry = StepEntryTranslator.TryTranslate(mock.Object);
|
||||
|
||||
Assert.NotNull(entry);
|
||||
Assert.Equal(JobExecutionPhase.Pre, entry.Phase);
|
||||
Assert.Equal("Pre Run actions/checkout@v4", entry.DisplayName);
|
||||
Assert.Equal("actions/checkout@v4", entry.Uses);
|
||||
Assert.Null(entry.Run);
|
||||
Assert.Null(entry.SourcePath);
|
||||
Assert.Equal(0, entry.SourceLine);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Translate_ActionRunnerMain_ReturnsMainEntryWithUses()
|
||||
{
|
||||
var reference = new RepositoryPathReference
|
||||
{
|
||||
Name = "actions/setup-node",
|
||||
Path = "subdir",
|
||||
Ref = "v3",
|
||||
};
|
||||
var mock = NewActionRunnerMock(ActionRunStage.Main, "Run actions/setup-node@v3", reference);
|
||||
|
||||
var entry = StepEntryTranslator.TryTranslate(mock.Object);
|
||||
|
||||
Assert.NotNull(entry);
|
||||
Assert.Equal(JobExecutionPhase.Main, entry.Phase);
|
||||
Assert.Equal("actions/setup-node/subdir@v3", entry.Uses);
|
||||
Assert.Null(entry.Run);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Translate_ActionRunnerMain_ScriptReference_LeavesUsesNull()
|
||||
{
|
||||
var mock = NewActionRunnerMock(ActionRunStage.Main, "Run echo hi", new ScriptReference());
|
||||
|
||||
var entry = StepEntryTranslator.TryTranslate(mock.Object);
|
||||
|
||||
Assert.NotNull(entry);
|
||||
Assert.Equal(JobExecutionPhase.Main, entry.Phase);
|
||||
Assert.Null(entry.Uses);
|
||||
Assert.Null(entry.Run);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Translate_ActionRunnerMain_ContainerReference_UsesImage()
|
||||
{
|
||||
var reference = new ContainerRegistryReference { Image = "alpine:3.18" };
|
||||
var mock = NewActionRunnerMock(ActionRunStage.Main, "Run alpine", reference);
|
||||
|
||||
var entry = StepEntryTranslator.TryTranslate(mock.Object);
|
||||
|
||||
Assert.NotNull(entry);
|
||||
Assert.Equal("alpine:3.18", entry.Uses);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Translate_ActionRunnerPost_ReturnsPostEntry()
|
||||
{
|
||||
var reference = new RepositoryPathReference { Name = "actions/cache", Ref = "v3" };
|
||||
var mock = NewActionRunnerMock(ActionRunStage.Post, "Post Run actions/cache@v3", reference);
|
||||
|
||||
var entry = StepEntryTranslator.TryTranslate(mock.Object);
|
||||
|
||||
Assert.NotNull(entry);
|
||||
Assert.Equal(JobExecutionPhase.Post, entry.Phase);
|
||||
Assert.Equal("Post Run actions/cache@v3", entry.DisplayName);
|
||||
Assert.Equal("actions/cache@v3", entry.Uses);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Translate_ActionRunner_NullAction_LeavesUsesNull()
|
||||
{
|
||||
var mock = new Mock<IActionRunner>();
|
||||
mock.SetupGet(x => x.Stage).Returns(ActionRunStage.Main);
|
||||
mock.SetupGet(x => x.DisplayName).Returns("anonymous");
|
||||
mock.SetupGet(x => x.Action).Returns((ActionStep)null);
|
||||
|
||||
var entry = StepEntryTranslator.TryTranslate(mock.Object);
|
||||
|
||||
Assert.NotNull(entry);
|
||||
Assert.Equal("anonymous", entry.DisplayName);
|
||||
Assert.Null(entry.Uses);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Translate_ActionStep_ExtractsWith()
|
||||
{
|
||||
var reference = new RepositoryPathReference { Name = "actions/cache", Ref = "v5" };
|
||||
var action = new ActionStep
|
||||
{
|
||||
Reference = reference,
|
||||
Inputs = Map(("path", Str("prime-numbers")), ("key", Str("k"))),
|
||||
};
|
||||
var mock = NewActionRunnerMock(ActionRunStage.Main, "Cache", reference, action);
|
||||
|
||||
var entry = StepEntryTranslator.TryTranslate(mock.Object);
|
||||
|
||||
Assert.NotNull(entry);
|
||||
Assert.NotNull(entry.WithYaml);
|
||||
Assert.Contains("path: prime-numbers", entry.WithYaml);
|
||||
Assert.Contains("key: k", entry.WithYaml);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Translate_ActionStep_PreservesExpressionInWith()
|
||||
{
|
||||
var reference = new RepositoryPathReference { Name = "actions/cache", Ref = "v5" };
|
||||
var action = new ActionStep
|
||||
{
|
||||
Reference = reference,
|
||||
Inputs = Map(("key", Str("${{ runner.os }}-primes"))),
|
||||
};
|
||||
var mock = NewActionRunnerMock(ActionRunStage.Main, "Cache", reference, action);
|
||||
|
||||
var entry = StepEntryTranslator.TryTranslate(mock.Object);
|
||||
|
||||
Assert.NotNull(entry);
|
||||
Assert.Contains("${{ runner.os }}-primes", entry.WithYaml);
|
||||
Assert.DoesNotContain("Linux", entry.WithYaml);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Translate_RunStep_ExtractsScript()
|
||||
{
|
||||
var action = new ActionStep
|
||||
{
|
||||
Reference = new ScriptReference(),
|
||||
Inputs = Map(("script", Str("echo hi"))),
|
||||
};
|
||||
var mock = NewActionRunnerMock(ActionRunStage.Main, "Run echo", new ScriptReference(), action);
|
||||
|
||||
var entry = StepEntryTranslator.TryTranslate(mock.Object);
|
||||
|
||||
Assert.NotNull(entry);
|
||||
Assert.Null(entry.Uses);
|
||||
Assert.Equal("echo hi", entry.Run);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Translate_RunStep_ExtractsShellAndWorkingDirectory()
|
||||
{
|
||||
// The runner stores run-step inputs under the keys defined in
|
||||
// PipelineConstants.ScriptStepInputs (camelCase), NOT their
|
||||
// kebab-case workflow-YAML spellings — see
|
||||
// ActionManifestManagerWrapper:244.
|
||||
var action = new ActionStep
|
||||
{
|
||||
Reference = new ScriptReference(),
|
||||
Inputs = Map(
|
||||
(PipelineConstants.ScriptStepInputs.Script, Str("npm test")),
|
||||
(PipelineConstants.ScriptStepInputs.Shell, Str("bash")),
|
||||
(PipelineConstants.ScriptStepInputs.WorkingDirectory, Str("./api"))),
|
||||
};
|
||||
var mock = NewActionRunnerMock(ActionRunStage.Main, "Run", new ScriptReference(), action);
|
||||
|
||||
var entry = StepEntryTranslator.TryTranslate(mock.Object);
|
||||
|
||||
Assert.NotNull(entry);
|
||||
Assert.Equal("npm test", entry.Run);
|
||||
Assert.Equal("bash", entry.Shell);
|
||||
Assert.Equal("./api", entry.WorkingDirectory);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Translate_ActionStep_FiltersRunStepKeysFromWith()
|
||||
{
|
||||
// Defensive: an action step's Inputs should not contain
|
||||
// run-step internal keys, but if it did, they must not
|
||||
// surface in the with: rendering.
|
||||
var reference = new RepositoryPathReference { Name = "a/b", Ref = "v1" };
|
||||
var action = new ActionStep
|
||||
{
|
||||
Reference = reference,
|
||||
Inputs = Map(
|
||||
("mode", Str("ci")),
|
||||
(PipelineConstants.ScriptStepInputs.Script, Str("leak")),
|
||||
(PipelineConstants.ScriptStepInputs.Shell, Str("leak")),
|
||||
(PipelineConstants.ScriptStepInputs.WorkingDirectory, Str("leak"))),
|
||||
};
|
||||
var mock = NewActionRunnerMock(ActionRunStage.Main, "Run", reference, action);
|
||||
|
||||
var entry = StepEntryTranslator.TryTranslate(mock.Object);
|
||||
|
||||
Assert.NotNull(entry);
|
||||
Assert.NotNull(entry.WithYaml);
|
||||
Assert.Contains("mode: ci", entry.WithYaml);
|
||||
Assert.DoesNotContain("leak", entry.WithYaml);
|
||||
Assert.DoesNotContain(PipelineConstants.ScriptStepInputs.Script, entry.WithYaml);
|
||||
Assert.DoesNotContain(PipelineConstants.ScriptStepInputs.Shell, entry.WithYaml);
|
||||
Assert.DoesNotContain(PipelineConstants.ScriptStepInputs.WorkingDirectory, entry.WithYaml);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Translate_ActionStep_OmitsEmptyEnv()
|
||||
{
|
||||
var reference = new RepositoryPathReference { Name = "a/b", Ref = "v1" };
|
||||
var action = new ActionStep
|
||||
{
|
||||
Reference = reference,
|
||||
Environment = new MappingToken(null, null, null),
|
||||
};
|
||||
var mock = NewActionRunnerMock(ActionRunStage.Main, "Run", reference, action);
|
||||
|
||||
var entry = StepEntryTranslator.TryTranslate(mock.Object);
|
||||
|
||||
Assert.NotNull(entry);
|
||||
Assert.Null(entry.EnvYaml);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Translate_ActionStep_ExtractsEnv()
|
||||
{
|
||||
var reference = new RepositoryPathReference { Name = "a/b", Ref = "v1" };
|
||||
var action = new ActionStep
|
||||
{
|
||||
Reference = reference,
|
||||
Environment = Map(("NODE_ENV", Str("production"))),
|
||||
};
|
||||
var mock = NewActionRunnerMock(ActionRunStage.Main, "Run", reference, action);
|
||||
|
||||
var entry = StepEntryTranslator.TryTranslate(mock.Object);
|
||||
|
||||
Assert.NotNull(entry);
|
||||
Assert.NotNull(entry.EnvYaml);
|
||||
Assert.Contains("NODE_ENV: production", entry.EnvYaml);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("__1")]
|
||||
[InlineData("__123")]
|
||||
public void Translate_FiltersAutoGeneratedId(string contextName)
|
||||
{
|
||||
var reference = new RepositoryPathReference { Name = "a/b", Ref = "v1" };
|
||||
var action = new ActionStep
|
||||
{
|
||||
Reference = reference,
|
||||
ContextName = contextName,
|
||||
};
|
||||
var mock = NewActionRunnerMock(ActionRunStage.Main, "Run", reference, action);
|
||||
|
||||
var entry = StepEntryTranslator.TryTranslate(mock.Object);
|
||||
|
||||
Assert.NotNull(entry);
|
||||
Assert.Null(entry.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Translate_PreservesUserId()
|
||||
{
|
||||
var reference = new RepositoryPathReference { Name = "a/b", Ref = "v1" };
|
||||
var action = new ActionStep
|
||||
{
|
||||
Reference = reference,
|
||||
ContextName = "cache-primes",
|
||||
};
|
||||
var mock = NewActionRunnerMock(ActionRunStage.Main, "Cache", reference, action);
|
||||
|
||||
var entry = StepEntryTranslator.TryTranslate(mock.Object);
|
||||
|
||||
Assert.NotNull(entry);
|
||||
Assert.Equal("cache-primes", entry.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Translate_ActionStep_ExtractsCondition()
|
||||
{
|
||||
var reference = new RepositoryPathReference { Name = "a/b", Ref = "v1" };
|
||||
var action = new ActionStep
|
||||
{
|
||||
Reference = reference,
|
||||
Condition = "always()",
|
||||
};
|
||||
var mock = NewActionRunnerMock(ActionRunStage.Main, "Run", reference, action);
|
||||
|
||||
var entry = StepEntryTranslator.TryTranslate(mock.Object);
|
||||
|
||||
Assert.NotNull(entry);
|
||||
Assert.Equal("always()", entry.If);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Translate_PreEntry_OmitsUserParams()
|
||||
{
|
||||
// Pre entries stay minimal — they reference the same Action as
|
||||
// Main, and duplicating params adds noise.
|
||||
var reference = new RepositoryPathReference { Name = "a/b", Ref = "v1" };
|
||||
var action = new ActionStep
|
||||
{
|
||||
Reference = reference,
|
||||
ContextName = "user-id",
|
||||
Condition = "always()",
|
||||
Environment = Map(("X", Str("y"))),
|
||||
Inputs = Map(("k", Str("v"))),
|
||||
};
|
||||
var mock = NewActionRunnerMock(ActionRunStage.Pre, "Pre a/b@v1", reference, action);
|
||||
|
||||
var entry = StepEntryTranslator.TryTranslate(mock.Object);
|
||||
|
||||
Assert.NotNull(entry);
|
||||
Assert.Equal(JobExecutionPhase.Pre, entry.Phase);
|
||||
Assert.Null(entry.Id);
|
||||
Assert.Null(entry.If);
|
||||
Assert.Null(entry.EnvYaml);
|
||||
Assert.Null(entry.WithYaml);
|
||||
}
|
||||
}
|
||||
}
|
||||
191
src/Test/L0/Worker/TemplateTokenYamlAdapterL0.cs
Normal file
191
src/Test/L0/Worker/TemplateTokenYamlAdapterL0.cs
Normal file
@@ -0,0 +1,191 @@
|
||||
using GitHub.DistributedTask.ObjectTemplating.Tokens;
|
||||
using GitHub.Runner.Worker.Dap;
|
||||
using Xunit;
|
||||
|
||||
namespace GitHub.Runner.Common.Tests.Worker
|
||||
{
|
||||
public sealed class TemplateTokenYamlAdapterL0
|
||||
{
|
||||
private static StringToken Str(string s) => new(null, null, null, s);
|
||||
private static BooleanToken Bool(bool b) => new(null, null, null, b);
|
||||
private static NumberToken Num(double n) => new(null, null, null, n);
|
||||
private static BasicExpressionToken Expr(string s) => new(null, null, null, s);
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Serialize_StringScalar()
|
||||
{
|
||||
Assert.Equal("hello", TemplateTokenYamlAdapter.Serialize(Str("hello"), 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Serialize_BooleanScalar()
|
||||
{
|
||||
Assert.Equal("true", TemplateTokenYamlAdapter.Serialize(Bool(true), 0));
|
||||
Assert.Equal("false", TemplateTokenYamlAdapter.Serialize(Bool(false), 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Serialize_NumberScalar()
|
||||
{
|
||||
Assert.Equal("10", TemplateTokenYamlAdapter.Serialize(Num(10), 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Serialize_NullToken_RendersAsNull()
|
||||
{
|
||||
Assert.Equal("null", TemplateTokenYamlAdapter.Serialize(null, 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Serialize_PreservesBasicExpression()
|
||||
{
|
||||
var token = Expr("runner.os");
|
||||
string yaml = TemplateTokenYamlAdapter.Serialize(token, 0);
|
||||
Assert.Contains("${{ runner.os }}", yaml);
|
||||
Assert.DoesNotContain("Linux", yaml);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Serialize_PreservesCompositeExpressionInStringToken()
|
||||
{
|
||||
// A StringToken constructed directly with the literal text
|
||||
// round-trips unchanged. (The workflow parser does NOT produce
|
||||
// a StringToken for this input — see
|
||||
// Serialize_ReversesFormatRewriteForCompositeExpression — but
|
||||
// direct StringToken construction must still preserve the
|
||||
// literal verbatim.)
|
||||
var token = Str("${{ runner.os }}-primes");
|
||||
string yaml = TemplateTokenYamlAdapter.Serialize(token, 0);
|
||||
Assert.Contains("${{ runner.os }}-primes", yaml);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Serialize_ReversesFormatRewriteForCompositeExpression()
|
||||
{
|
||||
// The workflow parser tokenizes a mixed scalar like
|
||||
// `${{ runner.os }}-primes` as a single BasicExpressionToken
|
||||
// whose internal expression is `format('{0}-primes', runner.os)`.
|
||||
// The adapter must surface the author-facing form, not the
|
||||
// parser's normalized rewrite.
|
||||
var token = Expr("format('{0}-primes', runner.os)");
|
||||
string yaml = TemplateTokenYamlAdapter.Serialize(token, 0);
|
||||
Assert.Contains("${{ runner.os }}-primes", yaml);
|
||||
Assert.DoesNotContain("format(", yaml);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Serialize_NestedMapping()
|
||||
{
|
||||
var inner = new MappingToken(null, null, null);
|
||||
inner.Add(Str("b"), Num(1));
|
||||
inner.Add(Str("c"), Expr("x"));
|
||||
var outer = new MappingToken(null, null, null);
|
||||
outer.Add(Str("a"), inner);
|
||||
|
||||
string yaml = TemplateTokenYamlAdapter.Serialize(outer, 0);
|
||||
|
||||
Assert.Contains("a:", yaml);
|
||||
Assert.Contains("b: 1", yaml);
|
||||
Assert.Contains("c: ${{ x }}", yaml);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Serialize_EmptyMapping()
|
||||
{
|
||||
var token = new MappingToken(null, null, null);
|
||||
string yaml = TemplateTokenYamlAdapter.Serialize(token, 0);
|
||||
Assert.Equal("{}", yaml);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Serialize_EmptySequence()
|
||||
{
|
||||
var token = new SequenceToken(null, null, null);
|
||||
string yaml = TemplateTokenYamlAdapter.Serialize(token, 0);
|
||||
Assert.Equal("[]", yaml);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Serialize_MultilineString_UsesBlockScalar()
|
||||
{
|
||||
var token = Str("line1\nline2\nline3");
|
||||
string yaml = TemplateTokenYamlAdapter.Serialize(token, 0);
|
||||
// Block-literal indicator `|` appears for multi-line scalars.
|
||||
Assert.Contains("|", yaml);
|
||||
Assert.Contains("line1", yaml);
|
||||
Assert.Contains("line2", yaml);
|
||||
Assert.Contains("line3", yaml);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Serialize_IndentLevel_PrefixesNonEmptyLines()
|
||||
{
|
||||
var map = new MappingToken(null, null, null);
|
||||
map.Add(Str("k1"), Str("v1"));
|
||||
map.Add(Str("k2"), Str("v2"));
|
||||
|
||||
string yaml = TemplateTokenYamlAdapter.Serialize(map, indentSpaces: 4);
|
||||
|
||||
foreach (var line in yaml.Split('\n'))
|
||||
{
|
||||
if (line.Length > 0)
|
||||
{
|
||||
Assert.StartsWith(" ", line);
|
||||
}
|
||||
}
|
||||
Assert.Contains("k1: v1", yaml);
|
||||
Assert.Contains("k2: v2", yaml);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Serialize_NoTrailingNewline()
|
||||
{
|
||||
var token = Str("hello");
|
||||
string yaml = TemplateTokenYamlAdapter.Serialize(token, 0);
|
||||
Assert.False(yaml.EndsWith("\n"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Serialize_AlwaysUsesLfLineBreaks()
|
||||
{
|
||||
// Regression: YamlDotNet's Emitter calls WriteLine, which on
|
||||
// Windows produces CRLF (the host's Environment.NewLine).
|
||||
// Serialize must force LF so the rendered view round-trips
|
||||
// regardless of platform.
|
||||
var map = new MappingToken(null, null, null);
|
||||
map.Add(Str("k1"), Str("v1"));
|
||||
map.Add(Str("k2"), Num(2));
|
||||
map.Add(Str("k3"), Bool(true));
|
||||
string yaml = TemplateTokenYamlAdapter.Serialize(map, indentSpaces: 2);
|
||||
Assert.DoesNotContain("\r", yaml);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user