Composite Action Step Markers (#4243)

This commit is contained in:
eric sciple
2026-02-23 15:00:12 +00:00
committed by GitHub
parent a2b220990b
commit ecb5f298fa
6 changed files with 455 additions and 1 deletions
@@ -0,0 +1,271 @@
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using GitHub.DistributedTask.WebApi;
using GitHub.Runner.Common.Util;
using GitHub.Runner.Sdk;
using GitHub.Runner.Worker;
using GitHub.Runner.Worker.Handlers;
using Moq;
using Xunit;
using DTWebApi = GitHub.DistributedTask.WebApi;
namespace GitHub.Runner.Common.Tests.Worker.Handlers
{
public sealed class CompositeActionHandlerL0
{
// Test EscapeProperty helper logic via reflection or by testing the markers output
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void EscapeProperty_EscapesSpecialCharacters()
{
// Test the escaping logic that would be applied
var input = "value;with%special\r\n]chars";
var escaped = EscapeProperty(input);
Assert.Equal("value%3Bwith%25special%0D%0A%5Dchars", escaped);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void EscapeProperty_HandlesNullAndEmpty()
{
Assert.Null(EscapeProperty(null));
Assert.Equal("", EscapeProperty(""));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void SanitizeDisplayName_TruncatesLongNames()
{
var longName = new string('a', 1500);
var sanitized = SanitizeDisplayName(longName);
Assert.Equal(CompositeActionHandler.MaxDisplayNameLength, sanitized.Length);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void SanitizeDisplayName_TakesFirstLineOnly()
{
var multiline = "First line\nSecond line\nThird line";
var sanitized = SanitizeDisplayName(multiline);
Assert.Equal("First line", sanitized);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void SanitizeDisplayName_TrimsLeadingWhitespace()
{
var withLeading = " \n \t Actual name\nSecond line";
var sanitized = SanitizeDisplayName(withLeading);
Assert.Equal("Actual name", sanitized);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void SanitizeDisplayName_HandlesCarriageReturn()
{
var withCR = "First line\r\nSecond line";
var sanitized = SanitizeDisplayName(withCR);
Assert.Equal("First line", sanitized);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void SanitizeDisplayName_HandlesNullAndEmpty()
{
Assert.Null(SanitizeDisplayName(null));
Assert.Equal("", SanitizeDisplayName(""));
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void EmitMarkers_DisplayNameEscaping()
{
// Verify that special characters in display names get escaped properly
var displayName = "Step with semicolons; and more; here";
var escaped = EscapeProperty(SanitizeDisplayName(displayName));
Assert.Equal("Step with semicolons%3B and more%3B here", escaped);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void EmitMarkers_DisplayNameWithBrackets()
{
var displayName = "Step with [brackets] inside";
var escaped = EscapeProperty(SanitizeDisplayName(displayName));
Assert.Equal("Step with [brackets%5D inside", escaped);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void StripUserEmittedMarkers_StartAction()
{
// Simulate what OutputManager does to strip markers
var userLine = "##[start-action display=Fake;id=fake]";
var stripped = StripMarkers(userLine);
Assert.Equal(@"##[\start-action display=Fake;id=fake]", stripped);
Assert.DoesNotContain("##[start-action", stripped);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void StripUserEmittedMarkers_EndAction()
{
var userLine = "##[end-action id=fake;outcome=success;conclusion=success;duration_ms=100]";
var stripped = StripMarkers(userLine);
Assert.Equal(@"##[\end-action id=fake;outcome=success;conclusion=success;duration_ms=100]", stripped);
Assert.DoesNotContain("##[end-action", stripped);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void StripUserEmittedMarkers_PreservesOtherCommands()
{
var userLine = "##[group]My Group";
var stripped = StripMarkers(userLine);
Assert.Equal("##[group]My Group", stripped);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void StripUserEmittedMarkers_HandlesEmbeddedMarkers()
{
var userLine = "Some text ##[start-action display=fake;id=fake] more text";
var stripped = StripMarkers(userLine);
Assert.Equal(@"Some text ##[\start-action display=fake;id=fake] more text", stripped);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void TaskResultToActionResult_Success()
{
var result = GitHub.DistributedTask.WebApi.TaskResult.Succeeded;
var actionResult = result.ToActionResult();
Assert.Equal(ActionResult.Success, actionResult);
Assert.Equal("success", actionResult.ToString().ToLowerInvariant());
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void TaskResultToActionResult_Failure()
{
var result = GitHub.DistributedTask.WebApi.TaskResult.Failed;
var actionResult = result.ToActionResult();
Assert.Equal(ActionResult.Failure, actionResult);
Assert.Equal("failure", actionResult.ToString().ToLowerInvariant());
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void TaskResultToActionResult_Cancelled()
{
var result = GitHub.DistributedTask.WebApi.TaskResult.Canceled;
var actionResult = result.ToActionResult();
Assert.Equal(ActionResult.Cancelled, actionResult);
Assert.Equal("cancelled", actionResult.ToString().ToLowerInvariant());
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void TaskResultToActionResult_Skipped()
{
var result = GitHub.DistributedTask.WebApi.TaskResult.Skipped;
var actionResult = result.ToActionResult();
Assert.Equal(ActionResult.Skipped, actionResult);
Assert.Equal("skipped", actionResult.ToString().ToLowerInvariant());
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void MarkerFormat_StartAction()
{
var display = "My Step";
var id = "my-step";
var marker = $"##[start-action display={EscapeProperty(display)};id={EscapeProperty(id)}]";
Assert.Equal("##[start-action display=My Step;id=my-step]", marker);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void MarkerFormat_EndAction()
{
var id = "my-step";
var outcome = "success";
var conclusion = "success";
var durationMs = 1234;
var marker = $"##[end-action id={EscapeProperty(id)};outcome={outcome};conclusion={conclusion};duration_ms={durationMs}]";
Assert.Equal("##[end-action id=my-step;outcome=success;conclusion=success;duration_ms=1234]", marker);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void MarkerFormat_NestedId()
{
var prefix = "outer-composite";
var contextName = "inner-step";
var stepId = $"{prefix}.{contextName}";
Assert.Equal("outer-composite.inner-step", stepId);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void MarkerFormat_SkippedStep()
{
var id = "skipped-step";
var marker = $"##[end-action id={EscapeProperty(id)};outcome=skipped;conclusion=skipped;duration_ms=0]";
Assert.Equal("##[end-action id=skipped-step;outcome=skipped;conclusion=skipped;duration_ms=0]", marker);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void MarkerFormat_ContinueOnError()
{
// When continue-on-error is true and step fails:
// outcome = failure (raw result)
// conclusion = success (after continue-on-error applied)
var id = "failing-step";
var marker = $"##[end-action id={EscapeProperty(id)};outcome=failure;conclusion=success;duration_ms=500]";
Assert.Equal("##[end-action id=failing-step;outcome=failure;conclusion=success;duration_ms=500]", marker);
}
// Helper methods that call the real production code
private static string EscapeProperty(string value) =>
CompositeActionHandler.EscapeProperty(value);
private static string SanitizeDisplayName(string displayName) =>
CompositeActionHandler.SanitizeDisplayName(displayName);
private static string StripMarkers(string line)
{
if (!string.IsNullOrEmpty(line) &&
(line.Contains("##[start-action") || line.Contains("##[end-action")))
{
line = line.Replace("##[start-action", @"##[\start-action")
.Replace("##[end-action", @"##[\end-action");
}
return line;
}
}
}
+60
View File
@@ -1006,6 +1006,66 @@ namespace GitHub.Runner.Common.Tests.Worker
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void StripCompositeMarkers_StartAction()
{
using (Setup())
using (_outputManager)
{
Process("##[start-action display=Fake;id=fake]");
Assert.Single(_messages);
Assert.Contains(@"##[\start-action", _messages[0]);
Assert.DoesNotContain("##[start-action", _messages[0]);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void StripCompositeMarkers_EndAction()
{
using (Setup())
using (_outputManager)
{
Process("##[end-action id=fake;outcome=success;conclusion=success;duration_ms=100]");
Assert.Single(_messages);
Assert.Contains(@"##[\end-action", _messages[0]);
Assert.DoesNotContain("##[end-action", _messages[0]);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void StripCompositeMarkers_PreservesOtherCommands()
{
using (Setup())
using (_outputManager)
{
Process("##[group]My Group");
// Should not be stripped (not a composite marker)
Assert.Single(_messages);
Assert.Equal("##[group]My Group", _messages[0]);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void StripCompositeMarkers_EmbeddedInLine()
{
using (Setup())
using (_outputManager)
{
Process("Some text ##[start-action display=fake;id=fake] more text");
Assert.Single(_messages);
Assert.Contains(@"##[\start-action", _messages[0]);
Assert.DoesNotContain("##[start-action", _messages[0]);
}
}
private TestHostContext Setup(
[CallerMemberName] string name = "",
IssueMatchersConfig matchers = null,