mirror of
https://github.com/actions/runner.git
synced 2026-07-05 12:11:57 +08:00
Compare commits
2 Commits
dap-execut
...
dap-templa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa5aaea56a | ||
|
|
4dbc4349d6 |
276
src/Runner.Worker/Dap/JobExecutionView.cs
Normal file
276
src/Runner.Worker/Dap/JobExecutionView.cs
Normal file
@@ -0,0 +1,276 @@
|
||||
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];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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()}'.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
442
src/Test/L0/Worker/JobExecutionViewL0.cs
Normal file
442
src/Test/L0/Worker/JobExecutionViewL0.cs
Normal file
@@ -0,0 +1,442 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using GitHub.Runner.Worker;
|
||||
using GitHub.Runner.Worker.Dap;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace GitHub.Runner.Common.Tests.Worker
|
||||
{
|
||||
public sealed class JobExecutionViewL0
|
||||
{
|
||||
private static JobExecutionViewEntry MainEntry(string name)
|
||||
{
|
||||
return new JobExecutionViewEntry(JobExecutionPhase.Main, name, run: name);
|
||||
}
|
||||
|
||||
private static IStep NewStep(string displayName = "step")
|
||||
{
|
||||
var mock = new Mock<IStep>();
|
||||
mock.Setup(s => s.DisplayName).Returns(displayName);
|
||||
return mock.Object;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Constructor_RendersEmptyView()
|
||||
{
|
||||
var view = new JobExecutionView("my-job");
|
||||
|
||||
Assert.Equal(0, view.EntryCount);
|
||||
Assert.Contains("# Job: my-job", view.Yaml);
|
||||
Assert.Contains("- step: Setup job", view.Yaml);
|
||||
Assert.Contains("- step: Complete job", view.Yaml);
|
||||
|
||||
// Only the two synthetic boundaries appear.
|
||||
int stepCount = view.Yaml.Split("- step: ").Length - 1;
|
||||
Assert.Equal(2, stepCount);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void Constructor_ThrowsOnInvalidJobId(string jobId)
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => new JobExecutionView(jobId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Append_IncrementsEntryCount()
|
||||
{
|
||||
var view = new JobExecutionView("j");
|
||||
|
||||
int line0 = view.Append(MainEntry("a"));
|
||||
int line1 = view.Append(MainEntry("b"));
|
||||
int line2 = view.Append(MainEntry("c"));
|
||||
|
||||
Assert.Equal(3, view.EntryCount);
|
||||
Assert.True(line0 < line1);
|
||||
Assert.True(line1 < line2);
|
||||
Assert.Equal(line0, view.GetLine(0));
|
||||
Assert.Equal(line1, view.GetLine(1));
|
||||
Assert.Equal(line2, view.GetLine(2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Append_PreservesPriorEntryLines()
|
||||
{
|
||||
var view = new JobExecutionView("j");
|
||||
|
||||
int l0 = view.Append(MainEntry("a"));
|
||||
int l1 = view.Append(MainEntry("b"));
|
||||
int l2 = view.Append(MainEntry("c"));
|
||||
|
||||
view.Append(MainEntry("d"));
|
||||
Assert.Equal(l0, view.GetLine(0));
|
||||
Assert.Equal(l1, view.GetLine(1));
|
||||
Assert.Equal(l2, view.GetLine(2));
|
||||
|
||||
view.Append(MainEntry("e"));
|
||||
Assert.Equal(l0, view.GetLine(0));
|
||||
Assert.Equal(l1, view.GetLine(1));
|
||||
Assert.Equal(l2, view.GetLine(2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Append_RegistersStepIdentity()
|
||||
{
|
||||
var view = new JobExecutionView("j");
|
||||
var step = NewStep();
|
||||
|
||||
int line = view.Append(MainEntry("a"), step);
|
||||
|
||||
Assert.Equal(line, view.GetLine(0));
|
||||
Assert.Equal(line, view.TryGetLineForStep(step));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Append_NullStepIdentity_StillAppends()
|
||||
{
|
||||
var view = new JobExecutionView("j");
|
||||
|
||||
view.Append(MainEntry("a"), stepIdentity: null);
|
||||
|
||||
Assert.Equal(1, view.EntryCount);
|
||||
Assert.Null(view.TryGetLineForStep(null));
|
||||
Assert.Contains("- step: a", view.Yaml);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Append_DuplicateStepIdentity_Throws()
|
||||
{
|
||||
var view = new JobExecutionView("j");
|
||||
var step = NewStep();
|
||||
|
||||
view.Append(MainEntry("a"), step);
|
||||
Assert.Throws<InvalidOperationException>(() => view.Append(MainEntry("b"), step));
|
||||
|
||||
// State preserved: only the first entry is present.
|
||||
Assert.Equal(1, view.EntryCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Append_NullEntry_Throws()
|
||||
{
|
||||
var view = new JobExecutionView("j");
|
||||
Assert.Throws<ArgumentNullException>(() => view.Append(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void AppendRange_AppendsAllAndRendersOnce()
|
||||
{
|
||||
var view = new JobExecutionView("j");
|
||||
var steps = Enumerable.Range(0, 5).Select(i => NewStep("s" + i)).ToList();
|
||||
var items = steps
|
||||
.Select((s, i) => (entry: MainEntry("e" + i), stepIdentity: s))
|
||||
.ToList();
|
||||
|
||||
view.AppendRange(items);
|
||||
|
||||
Assert.Equal(5, view.EntryCount);
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
int line = view.GetLine(i);
|
||||
Assert.Equal(line, view.TryGetLineForStep(steps[i]));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void AppendRange_RejectsDuplicateInInput()
|
||||
{
|
||||
var view = new JobExecutionView("j");
|
||||
var dup = NewStep();
|
||||
var items = new List<(JobExecutionViewEntry, IStep)>
|
||||
{
|
||||
(MainEntry("a"), dup),
|
||||
(MainEntry("b"), dup),
|
||||
};
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => view.AppendRange(items));
|
||||
Assert.Equal(0, view.EntryCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void AppendRange_RejectsOverlapWithExisting()
|
||||
{
|
||||
var view = new JobExecutionView("j");
|
||||
var step = NewStep();
|
||||
view.Append(MainEntry("a"), step);
|
||||
|
||||
var items = new List<(JobExecutionViewEntry, IStep)>
|
||||
{
|
||||
(MainEntry("b"), step),
|
||||
};
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => view.AppendRange(items));
|
||||
Assert.Equal(1, view.EntryCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void AppendRange_NullItems_Throws()
|
||||
{
|
||||
var view = new JobExecutionView("j");
|
||||
Assert.Throws<ArgumentNullException>(() => view.AppendRange(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void TryGetLineForStep_NullStep_ReturnsNull()
|
||||
{
|
||||
var view = new JobExecutionView("j");
|
||||
Assert.Null(view.TryGetLineForStep(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void TryGetLineForStep_UnknownStep_ReturnsNull()
|
||||
{
|
||||
var view = new JobExecutionView("j");
|
||||
var step = NewStep();
|
||||
Assert.Null(view.TryGetLineForStep(step));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
[InlineData(-1)]
|
||||
[InlineData(2)]
|
||||
public void GetLine_OutOfRange_Throws(int index)
|
||||
{
|
||||
var view = new JobExecutionView("j");
|
||||
view.Append(MainEntry("a"));
|
||||
view.Append(MainEntry("b"));
|
||||
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => view.GetLine(index));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Yaml_UpdatesAfterAppend()
|
||||
{
|
||||
var view = new JobExecutionView("j");
|
||||
view.Append(MainEntry("first"));
|
||||
string before = view.Yaml;
|
||||
Assert.Contains("- step: first", before);
|
||||
|
||||
view.Append(MainEntry("second"));
|
||||
string after = view.Yaml;
|
||||
|
||||
Assert.Contains("- step: first", after);
|
||||
Assert.Contains("- step: second", after);
|
||||
Assert.NotEqual(before, after);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Yaml_AlwaysEndsWithCleanupBoundary()
|
||||
{
|
||||
var view = new JobExecutionView("j");
|
||||
Assert.EndsWith("cleanup:\n - step: Complete job\n", view.Yaml);
|
||||
|
||||
view.Append(MainEntry("a"));
|
||||
Assert.EndsWith("cleanup:\n - step: Complete job\n", view.Yaml);
|
||||
|
||||
view.Append(MainEntry("b"));
|
||||
view.Append(MainEntry("c"));
|
||||
Assert.EndsWith("cleanup:\n - step: Complete job\n", view.Yaml);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Append_WithMatchKey_TracksUnclaimed()
|
||||
{
|
||||
var view = new JobExecutionView("j");
|
||||
|
||||
int line = view.Append(MainEntry("placeholder"), stepIdentity: null, matchKey: "k1");
|
||||
|
||||
var step = NewStep("real");
|
||||
int? claimed = view.TryClaim("k1", step);
|
||||
Assert.Equal(line, claimed);
|
||||
Assert.Equal(line, view.TryGetLineForStep(step));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void TryClaim_UnknownKey_ReturnsNull()
|
||||
{
|
||||
var view = new JobExecutionView("j");
|
||||
view.Append(MainEntry("a"), stepIdentity: null, matchKey: "k1");
|
||||
|
||||
Assert.Null(view.TryClaim("nope", NewStep()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void TryClaim_AlreadyClaimed_ReturnsNull()
|
||||
{
|
||||
var view = new JobExecutionView("j");
|
||||
view.Append(MainEntry("a"), stepIdentity: null, matchKey: "k1");
|
||||
|
||||
var first = NewStep("first");
|
||||
Assert.NotNull(view.TryClaim("k1", first));
|
||||
|
||||
var second = NewStep("second");
|
||||
Assert.Null(view.TryClaim("k1", second));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void TryClaim_StepAlreadyRegistered_ReturnsNull()
|
||||
{
|
||||
var view = new JobExecutionView("j");
|
||||
var step = NewStep();
|
||||
// Step is registered for the first entry.
|
||||
view.Append(MainEntry("a"), step);
|
||||
// A placeholder is registered for the second entry.
|
||||
view.Append(MainEntry("b"), stepIdentity: null, matchKey: "k1");
|
||||
|
||||
// Trying to claim the placeholder with the already-registered
|
||||
// step must return null (defensive — would otherwise double-bind).
|
||||
Assert.Null(view.TryClaim("k1", step));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Append_DuplicateMatchKey_Throws()
|
||||
{
|
||||
var view = new JobExecutionView("j");
|
||||
view.Append(MainEntry("a"), stepIdentity: null, matchKey: "k1");
|
||||
|
||||
Assert.Throws<InvalidOperationException>(
|
||||
() => view.Append(MainEntry("b"), stepIdentity: null, matchKey: "k1"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Append_MatchKeyNull_BehavesLikeOldOverload()
|
||||
{
|
||||
var view = new JobExecutionView("j");
|
||||
var step = NewStep();
|
||||
|
||||
int line = view.Append(MainEntry("a"), step);
|
||||
|
||||
Assert.Equal(line, view.GetLine(0));
|
||||
Assert.Equal(line, view.TryGetLineForStep(step));
|
||||
// TryClaim with any key must return null since no matchKey was registered.
|
||||
Assert.Null(view.TryClaim("anything", NewStep()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void TryClaim_AfterClaim_TryGetLineForStepResolves()
|
||||
{
|
||||
var view = new JobExecutionView("j");
|
||||
int line = view.Append(MainEntry("placeholder"), stepIdentity: null, matchKey: "k1");
|
||||
|
||||
var step = NewStep();
|
||||
Assert.Equal(line, view.TryClaim("k1", step));
|
||||
Assert.Equal(line, view.TryGetLineForStep(step));
|
||||
|
||||
// And a later Append doesn't lose the claim (Render rebuilds
|
||||
// the IStep -> line map from the persisted identities).
|
||||
view.Append(MainEntry("b"));
|
||||
Assert.Equal(line, view.TryGetLineForStep(step));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void TryClaim_NullArgs_Throws()
|
||||
{
|
||||
var view = new JobExecutionView("j");
|
||||
Assert.Throws<ArgumentNullException>(() => view.TryClaim(null, NewStep()));
|
||||
Assert.Throws<ArgumentNullException>(() => view.TryClaim("k", null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public async Task ConcurrentAppends_DontCorruptState()
|
||||
{
|
||||
var view = new JobExecutionView("j");
|
||||
const int N = 50;
|
||||
var steps = Enumerable.Range(0, N).Select(i => NewStep("s" + i)).ToList();
|
||||
var returnedLines = new ConcurrentBag<int>();
|
||||
|
||||
var tasks = Enumerable.Range(0, N).Select(i => Task.Run(() =>
|
||||
{
|
||||
int line = view.Append(MainEntry("e" + i), steps[i]);
|
||||
returnedLines.Add(line);
|
||||
})).ToArray();
|
||||
|
||||
await Task.WhenAll(tasks);
|
||||
|
||||
Assert.Equal(N, view.EntryCount);
|
||||
Assert.Equal(N, returnedLines.Distinct().Count());
|
||||
|
||||
// Every step identity resolves to some line in [0, N).
|
||||
var entryLines = Enumerable.Range(0, N).Select(view.GetLine).ToHashSet();
|
||||
Assert.Equal(N, entryLines.Count);
|
||||
foreach (var step in steps)
|
||||
{
|
||||
int? line = view.TryGetLineForStep(step);
|
||||
Assert.NotNull(line);
|
||||
Assert.Contains(line.Value, entryLines);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Level", "L0")]
|
||||
[Trait("Category", "Worker")]
|
||||
public void Append_RejectsBothStepIdentityAndMatchKey()
|
||||
{
|
||||
// Allowing both would orphan the IStep→line mapping the moment
|
||||
// TryClaim overwrites _stepIdentities[index] for a different
|
||||
// step, so the API rejects the combination at append time.
|
||||
var view = new JobExecutionView("j");
|
||||
var entry = new JobExecutionViewEntry(JobExecutionPhase.Post, "Post X", uses: "actions/x@v1");
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
view.Append(entry, stepIdentity: NewStep("real"), matchKey: "k1"));
|
||||
// State unchanged.
|
||||
Assert.Equal(0, view.EntryCount);
|
||||
}
|
||||
}
|
||||
}
|
||||
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