mirror of
https://github.com/actions/runner.git
synced 2025-12-12 15:13:30 +00:00
* [1742] Ensure multiple composite annoations are correctly written. This implementation uses a collector pattern to allow embedded ExecutionContexts to stash Issue objects for later processing by a non-embedded ancestor ExecutionContext. Also: - Provide explicit constructor implementations for ExecutionContext - Leverage explicit constructors to solidify immutability of several ExecutionContext class members. - Fixed erroneous call to ExecutionContext.Complete in CompositeActionHandler.cs - Use a consistent timestamp for FinishTime in ExecutionContext::Complete * Ensure collected issues are processed only by a non-embedded ExecutionContext. This was already implicit. Now, just making it explicit. * Provide a clear mechanism that allows callers to opt-in/opt-out of ExecutionContext::AddIssue's logging behavior. * Addressed deserialization inconsistencies in TimelineRecord.cs * Added TimelineRecord unit tests. * Refined unit tests related to TimelineRecord::Variables case-insensitivity * Add a unit test that verifies ExecutionContextLogOptions::LogMessageOverride has the desired effect. * Responded to PR feedback. * Don't allow embedded ExecutionContexts to add Issues to a TimelineRecord
271 lines
11 KiB
C#
271 lines
11 KiB
C#
#nullable enable
|
|
|
|
using System;
|
|
using System.IO;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Runtime.Serialization.Json;
|
|
using GitHub.DistributedTask.WebApi;
|
|
using Xunit;
|
|
using System.Text;
|
|
|
|
namespace GitHub.Runner.Common.Tests.DistributedTask
|
|
{
|
|
public sealed class TimelineRecordL0
|
|
{
|
|
[Fact]
|
|
[Trait("Level", "L0")]
|
|
[Trait("Category", "DistributedTask")]
|
|
public void VerifyTimelineRecord_Defaults()
|
|
{
|
|
var tr = new TimelineRecord();
|
|
|
|
Assert.Equal(0, tr.ErrorCount);
|
|
Assert.Equal(0, tr.WarningCount);
|
|
Assert.Equal(0, tr.NoticeCount);
|
|
Assert.Equal(1, tr.Attempt);
|
|
Assert.NotNull(tr.Issues);
|
|
Assert.NotNull(tr.PreviousAttempts);
|
|
Assert.NotNull(tr.Variables);
|
|
}
|
|
|
|
[Fact]
|
|
[Trait("Level", "L0")]
|
|
[Trait("Category", "DistributedTask")]
|
|
public void VerifyTimelineRecord_Clone()
|
|
{
|
|
var original = new TimelineRecord();
|
|
original.ErrorCount = 100;
|
|
original.WarningCount = 200;
|
|
original.NoticeCount = 300;
|
|
original.Attempt = 3;
|
|
|
|
// The Variables dictionary should be a case-insensitive dictionary.
|
|
original.Variables["xxx"] = new VariableValue("first", false);
|
|
original.Variables["XXX"] = new VariableValue("second", false);
|
|
|
|
Assert.Equal(1, original.Variables.Count);
|
|
Assert.Equal("second", original.Variables.Values.First().Value);
|
|
Assert.Equal("second", original.Variables["xXx"].Value);
|
|
|
|
var clone = original.Clone();
|
|
|
|
Assert.NotSame(original, clone);
|
|
Assert.NotSame(original.Variables, clone.Variables);
|
|
Assert.Equal(100, clone.ErrorCount);
|
|
Assert.Equal(200, clone.WarningCount);
|
|
Assert.Equal(300, clone.NoticeCount);
|
|
Assert.Equal(3, clone.Attempt);
|
|
|
|
// Now, mutate the original post-clone.
|
|
original.ErrorCount++;
|
|
original.WarningCount += 10;
|
|
original.NoticeCount *= 3;
|
|
original.Attempt--;
|
|
original.Variables["a"] = new VariableValue("1", false);
|
|
|
|
// Verify that the clone was unaffected by the changes to the original.
|
|
Assert.Equal(100, clone.ErrorCount);
|
|
Assert.Equal(200, clone.WarningCount);
|
|
Assert.Equal(300, clone.NoticeCount);
|
|
Assert.Equal(3, clone.Attempt);
|
|
Assert.Equal(1, clone.Variables.Count);
|
|
Assert.Equal("second", clone.Variables.Values.First().Value);
|
|
|
|
// Verify that the clone's Variables dictionary is also case-sensitive.
|
|
clone.Variables["yyy"] = new VariableValue("third", false);
|
|
clone.Variables["YYY"] = new VariableValue("fourth", false);
|
|
|
|
Assert.Equal(2, clone.Variables.Count);
|
|
Assert.Equal("second", clone.Variables["xXx"].Value);
|
|
Assert.Equal("fourth", clone.Variables["yYy"].Value);
|
|
}
|
|
|
|
[Fact]
|
|
[Trait("Level", "L0")]
|
|
[Trait("Category", "DistributedTask")]
|
|
public void VerifyTimelineRecord_DeserializationEdgeCase_NonNullCollections()
|
|
{
|
|
var jsonSamples = LoadJsonSamples(JsonSamplesFilePath);
|
|
|
|
// Verify that missing JSON fields don't result in null values for collection properties.
|
|
var tr = Deserialize(jsonSamples["minimal"]);
|
|
Assert.NotNull(tr);
|
|
Assert.Equal("minimal", tr!.Name);
|
|
Assert.NotNull(tr.Issues);
|
|
Assert.NotNull(tr.PreviousAttempts);
|
|
Assert.NotNull(tr.Variables);
|
|
|
|
// Verify that explicitly-null JSON fields don't result in null values for collection properties.
|
|
// (Our deserialization logic should fix these up and instantiate an empty collection.)
|
|
tr = Deserialize(jsonSamples["explicit-null-collections"]);
|
|
Assert.NotNull(tr);
|
|
Assert.Equal("explicit-null-collections", tr!.Name);
|
|
Assert.NotNull(tr.Issues);
|
|
Assert.NotNull(tr.PreviousAttempts);
|
|
Assert.NotNull(tr.Variables);
|
|
}
|
|
|
|
[Fact]
|
|
[Trait("Level", "L0")]
|
|
[Trait("Category", "DistributedTask")]
|
|
public void VerifyTimelineRecord_DeserializationEdgeCase_AttemptCannotBeLessThan1()
|
|
{
|
|
var jsonSamples = LoadJsonSamples(JsonSamplesFilePath);
|
|
|
|
// Verify that 1 is the effective floor for TimelineRecord::Attempt.
|
|
var tr = Deserialize(jsonSamples["minimal"]);
|
|
Assert.NotNull(tr);
|
|
Assert.Equal("minimal", tr!.Name);
|
|
Assert.Equal(1, tr.Attempt);
|
|
|
|
tr = Deserialize(jsonSamples["invalid-attempt-value"]);
|
|
Assert.NotNull(tr);
|
|
Assert.Equal("invalid-attempt-value", tr!.Name);
|
|
Assert.Equal(1, tr.Attempt);
|
|
|
|
tr = Deserialize(jsonSamples["zero-attempt-value"]);
|
|
Assert.NotNull(tr);
|
|
Assert.Equal("zero-attempt-value", tr!.Name);
|
|
Assert.Equal(1, tr.Attempt);
|
|
}
|
|
|
|
[Fact]
|
|
[Trait("Level", "L0")]
|
|
[Trait("Category", "DistributedTask")]
|
|
public void VerifyTimelineRecord_DeserializationEdgeCase_HandleLegacyNullsGracefully()
|
|
{
|
|
var jsonSamples = LoadJsonSamples(JsonSamplesFilePath);
|
|
|
|
// Verify that nulls for ErrorCount, WarningCount, and NoticeCount are interpreted as 0.
|
|
var tr = Deserialize(jsonSamples["legacy-nulls"]);
|
|
Assert.NotNull(tr);
|
|
Assert.Equal("legacy-nulls", tr!.Name);
|
|
Assert.Equal(0, tr.ErrorCount);
|
|
Assert.Equal(0, tr.WarningCount);
|
|
Assert.Equal(0, tr.NoticeCount);
|
|
}
|
|
|
|
[Fact]
|
|
[Trait("Level", "L0")]
|
|
[Trait("Category", "DistributedTask")]
|
|
public void VerifyTimelineRecord_DeserializationEdgeCase_HandleMissingCountsGracefully()
|
|
{
|
|
var jsonSamples = LoadJsonSamples(JsonSamplesFilePath);
|
|
|
|
// Verify that nulls for ErrorCount, WarningCount, and NoticeCount are interpreted as 0.
|
|
var tr = Deserialize(jsonSamples["missing-counts"]);
|
|
Assert.NotNull(tr);
|
|
Assert.Equal("missing-counts", tr!.Name);
|
|
Assert.Equal(0, tr.ErrorCount);
|
|
Assert.Equal(0, tr.WarningCount);
|
|
Assert.Equal(0, tr.NoticeCount);
|
|
}
|
|
|
|
[Fact]
|
|
[Trait("Level", "L0")]
|
|
[Trait("Category", "DistributedTask")]
|
|
public void VerifyTimelineRecord_DeserializationEdgeCase_NonZeroCounts()
|
|
{
|
|
var jsonSamples = LoadJsonSamples(JsonSamplesFilePath);
|
|
|
|
// Verify that nulls for ErrorCount, WarningCount, and NoticeCount are interpreted as 0.
|
|
var tr = Deserialize(jsonSamples["non-zero-counts"]);
|
|
Assert.NotNull(tr);
|
|
Assert.Equal("non-zero-counts", tr!.Name);
|
|
Assert.Equal(10, tr.ErrorCount);
|
|
Assert.Equal(20, tr.WarningCount);
|
|
Assert.Equal(30, tr.NoticeCount);
|
|
}
|
|
|
|
[Fact]
|
|
[Trait("Level", "L0")]
|
|
[Trait("Category", "DistributedTask")]
|
|
public void VerifyTimelineRecord_Deserialization_LeanTimelineRecord()
|
|
{
|
|
var jsonSamples = LoadJsonSamples(JsonSamplesFilePath);
|
|
|
|
// Verify that a lean TimelineRecord can be deserialized.
|
|
var tr = Deserialize(jsonSamples["lean"]);
|
|
Assert.NotNull(tr);
|
|
Assert.Equal("lean", tr!.Name);
|
|
Assert.Equal(4, tr.Attempt);
|
|
Assert.Equal(1, tr.Issues.Count);
|
|
Assert.Equal(3, tr.Variables.Count);
|
|
Assert.Equal(3, tr.PreviousAttempts.Count);
|
|
}
|
|
|
|
[Fact]
|
|
[Trait("Level", "L0")]
|
|
[Trait("Category", "DistributedTask")]
|
|
public void VerifyTimelineRecord_Deserialization_VariablesDictionaryIsCaseInsensitive()
|
|
{
|
|
var jsonSamples = LoadJsonSamples(JsonSamplesFilePath);
|
|
|
|
var tr = Deserialize(jsonSamples["lean"]);
|
|
Assert.NotNull(tr);
|
|
Assert.Equal("lean", tr!.Name);
|
|
Assert.Equal(3, tr.Variables.Count);
|
|
|
|
// Verify that the Variables Dictionary is case-insensitive.
|
|
tr.Variables["X"] = new VariableValue("overwritten", false);
|
|
Assert.Equal(3, tr.Variables.Count);
|
|
|
|
tr.Variables["new"] = new VariableValue("new.1", false);
|
|
Assert.Equal(4, tr.Variables.Count);
|
|
|
|
tr.Variables["NEW"] = new VariableValue("new.2", false);
|
|
Assert.Equal(4, tr.Variables.Count);
|
|
}
|
|
|
|
[Fact]
|
|
[Trait("Level", "L0")]
|
|
[Trait("Category", "DistributedTask")]
|
|
public void VerifyTimelineRecord_DeserializationEdgeCase_DuplicateVariableKeysThrowsException()
|
|
{
|
|
var jsonSamples = LoadJsonSamples(JsonSamplesFilePath);
|
|
|
|
// We could be more forgiving in this case if we discover that it's not uncommon in Production for serialized TimelineRecords to:
|
|
// 1) get incorrectly instantiated with a case-sensitive Variables dictionary (in older versions, this was possible via TimelineRecord::Clone)
|
|
// 2) end up with case variations of the same key
|
|
// 3) make another serialization/deserialization round trip.
|
|
//
|
|
// If we wanted to grant clemency to such incorrectly-serialized TimelineRecords,
|
|
// the fix to TimelineRecord::EnsureInitialized would look something like the following:
|
|
//
|
|
// var seedVariables = m_variables ?? Enumerable.Empty<KeyValuePair<string, VariableValue>>();
|
|
// m_variables = new Dictionary<string, VariableValue>(seedVariables.Count(), StringComparer.OrdinalIgnoreCase);
|
|
// foreach (var kvp in seedVariables)
|
|
// {
|
|
// m_variables[kvp.Key] = kvp.Value;
|
|
// }
|
|
Assert.Throws<ArgumentException>(() => Deserialize(jsonSamples["duplicate-variable-keys"]));
|
|
}
|
|
|
|
|
|
private static Dictionary<string, string> LoadJsonSamples(string path)
|
|
{
|
|
// Embedding independent JSON samples within YML works well because JSON generally doesn't need to be escaped or otherwise mangled.
|
|
var yamlDeserializer = new YamlDotNet.Serialization.Deserializer();
|
|
using var stream = new StreamReader(path);
|
|
return yamlDeserializer.Deserialize<Dictionary<string, string>>(stream);
|
|
}
|
|
|
|
private static TimelineRecord? Deserialize(string rawJson)
|
|
{
|
|
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(rawJson ?? string.Empty));
|
|
return m_jsonSerializer.ReadObject(stream) as TimelineRecord;
|
|
}
|
|
|
|
private static string JsonSamplesFilePath
|
|
{
|
|
get
|
|
{
|
|
return Path.Combine(TestUtil.GetTestDataPath(), "timelinerecord_json_samples.yml");
|
|
}
|
|
}
|
|
|
|
private static readonly DataContractJsonSerializer m_jsonSerializer = new(typeof(TimelineRecord));
|
|
}
|
|
}
|