[1742] Ensure multiple composite annoations are correctly written. (#2311)

* [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
This commit is contained in:
John Wesley Walker III
2023-05-10 16:24:02 +02:00
committed by GitHub
parent 1bc14f0607
commit f8a28c3c4e
21 changed files with 794 additions and 270 deletions

View File

@@ -0,0 +1,270 @@
#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));
}
}

View File

@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.IO;
using System.Runtime.CompilerServices;
@@ -32,10 +32,10 @@ namespace GitHub.Runner.Common.Tests.Worker
hc.GetTrace().Info($"{tag} {line}");
return 1;
});
_ec.Setup(x => x.AddIssue(It.IsAny<Issue>(), It.IsAny<string>()))
.Callback((Issue issue, string message) =>
_ec.Setup(x => x.AddIssue(It.IsAny<Issue>(), It.IsAny<ExecutionContextLogOptions>()))
.Callback((Issue issue, ExecutionContextLogOptions logOptions) =>
{
hc.GetTrace().Info($"{issue.Type} {issue.Message} {message ?? string.Empty}");
hc.GetTrace().Info($"{issue.Type} {issue.Message} {logOptions.LogMessageOverride ?? string.Empty}");
});
_commandManager.EnablePluginInternalCommand();
@@ -59,10 +59,10 @@ namespace GitHub.Runner.Common.Tests.Worker
hc.GetTrace().Info($"{tag} {line}");
return 1;
});
_ec.Setup(x => x.AddIssue(It.IsAny<Issue>(), It.IsAny<string>()))
.Callback((Issue issue, string message) =>
_ec.Setup(x => x.AddIssue(It.IsAny<Issue>(), It.IsAny<ExecutionContextLogOptions>()))
.Callback((Issue issue, ExecutionContextLogOptions logOptions) =>
{
hc.GetTrace().Info($"{issue.Type} {issue.Message} {message ?? string.Empty}");
hc.GetTrace().Info($"{issue.Type} {issue.Message} {logOptions.LogMessageOverride ?? string.Empty}");
});
_commandManager.EnablePluginInternalCommand();
@@ -92,10 +92,10 @@ namespace GitHub.Runner.Common.Tests.Worker
return 1;
});
_ec.Setup(x => x.AddIssue(It.IsAny<Issue>(), It.IsAny<string>()))
.Callback((Issue issue, string message) =>
_ec.Setup(x => x.AddIssue(It.IsAny<Issue>(), It.IsAny<ExecutionContextLogOptions>()))
.Callback((Issue issue, ExecutionContextLogOptions logOptions) =>
{
hc.GetTrace().Info($"{issue.Type} {issue.Message} {message ?? string.Empty}");
hc.GetTrace().Info($"{issue.Type} {issue.Message} {logOptions.LogMessageOverride ?? string.Empty}");
});
_ec.Object.Global.EnvironmentVariables = new Dictionary<string, string>();

View File

@@ -2148,7 +2148,7 @@ runs:
_ec.Object.Global.FileTable = new List<String>();
_ec.Object.Global.Plan = new TaskOrchestrationPlanReference();
_ec.Setup(x => x.Write(It.IsAny<string>(), It.IsAny<string>())).Callback((string tag, string message) => { _hc.GetTrace().Info($"[{tag}]{message}"); });
_ec.Setup(x => x.AddIssue(It.IsAny<Issue>(), It.IsAny<string>())).Callback((Issue issue, string message) => { _hc.GetTrace().Info($"[{issue.Type}]{issue.Message ?? message}"); });
_ec.Setup(x => x.AddIssue(It.IsAny<Issue>(), It.IsAny<ExecutionContextLogOptions>())).Callback((Issue issue, ExecutionContextLogOptions logOptions) => { _hc.GetTrace().Info($"[{issue.Type}]{logOptions.LogMessageOverride ?? issue.Message}"); });
_ec.Setup(x => x.GetGitHubContext("workspace")).Returns(Path.Combine(_workFolder, "actions", "actions"));
_dockerManager = new Mock<IDockerCommandManager>();

View File

@@ -670,7 +670,7 @@ namespace GitHub.Runner.Common.Tests.Worker
{
Teardown();
}
}
}
[Fact]
[Trait("Level", "L0")]
@@ -715,7 +715,7 @@ namespace GitHub.Runner.Common.Tests.Worker
//Assert
var err = Assert.Throws<ArgumentException>(() => actionManifest.Load(_ec.Object, action_path));
Assert.Contains($"Fail to load {action_path}", err.Message);
_ec.Verify(x => x.AddIssue(It.Is<Issue>(s => s.Message.Contains("Missing 'using' value. 'using' requires 'composite', 'docker', 'node12' or 'node16'.")), It.IsAny<string>()), Times.Once);
_ec.Verify(x => x.AddIssue(It.Is<Issue>(s => s.Message.Contains("Missing 'using' value. 'using' requires 'composite', 'docker', 'node12' or 'node16'.")), It.IsAny<ExecutionContextLogOptions>()), Times.Once);
}
finally
{
@@ -860,7 +860,7 @@ namespace GitHub.Runner.Common.Tests.Worker
_ec.Setup(x => x.ExpressionValues).Returns(new DictionaryContextData());
_ec.Setup(x => x.ExpressionFunctions).Returns(new List<IFunctionInfo>());
_ec.Setup(x => x.Write(It.IsAny<string>(), It.IsAny<string>())).Callback((string tag, string message) => { _hc.GetTrace().Info($"{tag}{message}"); });
_ec.Setup(x => x.AddIssue(It.IsAny<Issue>(), It.IsAny<string>())).Callback((Issue issue, string message) => { _hc.GetTrace().Info($"[{issue.Type}]{issue.Message ?? message}"); });
_ec.Setup(x => x.AddIssue(It.IsAny<Issue>(), It.IsAny<ExecutionContextLogOptions>())).Callback((Issue issue, ExecutionContextLogOptions logOptions) => { _hc.GetTrace().Info($"[{issue.Type}]{logOptions.LogMessageOverride ?? issue.Message}"); });
}
private void Teardown()

View File

@@ -1,4 +1,4 @@
using GitHub.DistributedTask.Expressions2;
using GitHub.DistributedTask.Expressions2;
using GitHub.DistributedTask.ObjectTemplating.Tokens;
using GitHub.DistributedTask.Pipelines;
using GitHub.DistributedTask.Pipelines.ContextData;
@@ -366,7 +366,7 @@ namespace GitHub.Runner.Common.Tests.Worker
Assert.Equal("invalid1", finialInputs["invalid1"]);
Assert.Equal("invalid2", finialInputs["invalid2"]);
_ec.Verify(x => x.AddIssue(It.Is<Issue>(s => s.Message.Contains("Unexpected input(s) 'invalid1', 'invalid2'")), It.IsAny<string>()), Times.Once);
_ec.Verify(x => x.AddIssue(It.Is<Issue>(s => s.Message.Contains("Unexpected input(s) 'invalid1', 'invalid2'")), It.IsAny<ExecutionContextLogOptions>()), Times.Once);
}
[Fact]
@@ -485,7 +485,7 @@ namespace GitHub.Runner.Common.Tests.Worker
_ec.Setup(x => x.CancellationToken).Returns(_ecTokenSource.Token);
_ec.Object.Global.Variables = new Variables(_hc, new Dictionary<string, VariableValue>());
_ec.Setup(x => x.Write(It.IsAny<string>(), It.IsAny<string>())).Callback((string tag, string message) => { _hc.GetTrace().Info($"[{tag}]{message}"); });
_ec.Setup(x => x.AddIssue(It.IsAny<Issue>(), It.IsAny<string>())).Callback((Issue issue, string message) => { _hc.GetTrace().Info($"[{issue.Type}]{issue.Message ?? message}"); });
_ec.Setup(x => x.AddIssue(It.IsAny<Issue>(), It.IsAny<ExecutionContextLogOptions>())).Callback((Issue issue, ExecutionContextLogOptions logOptions) => { _hc.GetTrace().Info($"[{issue.Type}]{logOptions.LogMessageOverride ?? issue.Message}"); });
_hc.SetSingleton<IActionManager>(_actionManager.Object);
_hc.SetSingleton<IHandlerFactory>(_handlerFactory.Object);

View File

@@ -247,12 +247,16 @@ namespace GitHub.Runner.Common.Tests.Worker
WriteDebug = true,
Variables = _variables,
});
_executionContext.Setup(x => x.AddIssue(It.IsAny<DTWebApi.Issue>(), It.IsAny<string>()))
.Callback((DTWebApi.Issue issue, string logMessage) =>
_executionContext.Setup(x => x.AddIssue(It.IsAny<DTWebApi.Issue>(), It.IsAny<ExecutionContextLogOptions>()))
.Callback((DTWebApi.Issue issue, ExecutionContextLogOptions logOptions) =>
{
_issues.Add(new Tuple<DTWebApi.Issue, string>(issue, logMessage));
var message = !string.IsNullOrEmpty(logMessage) ? logMessage : issue.Message;
_trace.Info($"Issue '{issue.Type}': {message}");
var resolvedMessage = issue.Message;
if (logOptions.WriteToLog && !string.IsNullOrEmpty(logOptions.LogMessageOverride))
{
resolvedMessage = logOptions.LogMessageOverride;
}
_issues.Add(new(issue, resolvedMessage));
_trace.Info($"Issue '{issue.Type}': {resolvedMessage}");
});
_executionContext.Setup(x => x.Write(It.IsAny<string>(), It.IsAny<string>()))
.Callback((string tag, string message) =>
@@ -268,4 +272,4 @@ namespace GitHub.Runner.Common.Tests.Worker
return hostContext;
}
}
}
}

View File

@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.CompilerServices;
@@ -52,42 +52,43 @@ namespace GitHub.Runner.Common.Tests.Worker
// Act.
ec.InitializeJob(jobRequest, CancellationToken.None);
ec.AddIssue(new Issue() { Type = IssueType.Error, Message = "error" });
ec.AddIssue(new Issue() { Type = IssueType.Error, Message = "error" });
ec.AddIssue(new Issue() { Type = IssueType.Error, Message = "error" });
ec.AddIssue(new Issue() { Type = IssueType.Error, Message = "error" });
ec.AddIssue(new Issue() { Type = IssueType.Error, Message = "error" });
ec.AddIssue(new Issue() { Type = IssueType.Error, Message = "error" });
ec.AddIssue(new Issue() { Type = IssueType.Error, Message = "error" });
ec.AddIssue(new Issue() { Type = IssueType.Error, Message = "error" });
ec.AddIssue(new Issue() { Type = IssueType.Error, Message = "error" });
ec.AddIssue(new Issue() { Type = IssueType.Error, Message = "error" });
ec.AddIssue(new Issue() { Type = IssueType.Error, Message = "error" });
ec.AddIssue(new Issue() { Type = IssueType.Error, Message = "error" });
ec.AddIssue(new Issue() { Type = IssueType.Error, Message = "error" });
ec.AddIssue(new Issue() { Type = IssueType.Error, Message = "error" });
ec.AddIssue(new Issue() { Type = IssueType.Error, Message = "error" });
// Flood the ExecutionContext with errors and warnings (past its max capacity of 10).
ec.AddIssue(new Issue() { Type = IssueType.Error, Message = "error" }, ExecutionContextLogOptions.Default);
ec.AddIssue(new Issue() { Type = IssueType.Error, Message = "error" }, ExecutionContextLogOptions.Default);
ec.AddIssue(new Issue() { Type = IssueType.Error, Message = "error" }, ExecutionContextLogOptions.Default);
ec.AddIssue(new Issue() { Type = IssueType.Error, Message = "error" }, ExecutionContextLogOptions.Default);
ec.AddIssue(new Issue() { Type = IssueType.Error, Message = "error" }, ExecutionContextLogOptions.Default);
ec.AddIssue(new Issue() { Type = IssueType.Error, Message = "error" }, ExecutionContextLogOptions.Default);
ec.AddIssue(new Issue() { Type = IssueType.Error, Message = "error" }, ExecutionContextLogOptions.Default);
ec.AddIssue(new Issue() { Type = IssueType.Error, Message = "error" }, ExecutionContextLogOptions.Default);
ec.AddIssue(new Issue() { Type = IssueType.Error, Message = "error" }, ExecutionContextLogOptions.Default);
ec.AddIssue(new Issue() { Type = IssueType.Error, Message = "error" }, ExecutionContextLogOptions.Default);
ec.AddIssue(new Issue() { Type = IssueType.Error, Message = "error" }, ExecutionContextLogOptions.Default);
ec.AddIssue(new Issue() { Type = IssueType.Error, Message = "error" }, ExecutionContextLogOptions.Default);
ec.AddIssue(new Issue() { Type = IssueType.Error, Message = "error" }, ExecutionContextLogOptions.Default);
ec.AddIssue(new Issue() { Type = IssueType.Error, Message = "error" }, ExecutionContextLogOptions.Default);
ec.AddIssue(new Issue() { Type = IssueType.Error, Message = "error" }, ExecutionContextLogOptions.Default);
ec.AddIssue(new Issue() { Type = IssueType.Warning, Message = "warning" });
ec.AddIssue(new Issue() { Type = IssueType.Warning, Message = "warning" });
ec.AddIssue(new Issue() { Type = IssueType.Warning, Message = "warning" });
ec.AddIssue(new Issue() { Type = IssueType.Warning, Message = "warning" });
ec.AddIssue(new Issue() { Type = IssueType.Warning, Message = "warning" });
ec.AddIssue(new Issue() { Type = IssueType.Warning, Message = "warning" });
ec.AddIssue(new Issue() { Type = IssueType.Warning, Message = "warning" });
ec.AddIssue(new Issue() { Type = IssueType.Warning, Message = "warning" });
ec.AddIssue(new Issue() { Type = IssueType.Warning, Message = "warning" });
ec.AddIssue(new Issue() { Type = IssueType.Warning, Message = "warning" });
ec.AddIssue(new Issue() { Type = IssueType.Warning, Message = "warning" });
ec.AddIssue(new Issue() { Type = IssueType.Warning, Message = "warning" });
ec.AddIssue(new Issue() { Type = IssueType.Warning, Message = "warning" });
ec.AddIssue(new Issue() { Type = IssueType.Warning, Message = "warning" });
ec.AddIssue(new Issue() { Type = IssueType.Warning, Message = "warning" }, ExecutionContextLogOptions.Default);
ec.AddIssue(new Issue() { Type = IssueType.Warning, Message = "warning" }, ExecutionContextLogOptions.Default);
ec.AddIssue(new Issue() { Type = IssueType.Warning, Message = "warning" }, ExecutionContextLogOptions.Default);
ec.AddIssue(new Issue() { Type = IssueType.Warning, Message = "warning" }, ExecutionContextLogOptions.Default);
ec.AddIssue(new Issue() { Type = IssueType.Warning, Message = "warning" }, ExecutionContextLogOptions.Default);
ec.AddIssue(new Issue() { Type = IssueType.Warning, Message = "warning" }, ExecutionContextLogOptions.Default);
ec.AddIssue(new Issue() { Type = IssueType.Warning, Message = "warning" }, ExecutionContextLogOptions.Default);
ec.AddIssue(new Issue() { Type = IssueType.Warning, Message = "warning" }, ExecutionContextLogOptions.Default);
ec.AddIssue(new Issue() { Type = IssueType.Warning, Message = "warning" }, ExecutionContextLogOptions.Default);
ec.AddIssue(new Issue() { Type = IssueType.Warning, Message = "warning" }, ExecutionContextLogOptions.Default);
ec.AddIssue(new Issue() { Type = IssueType.Warning, Message = "warning" }, ExecutionContextLogOptions.Default);
ec.AddIssue(new Issue() { Type = IssueType.Warning, Message = "warning" }, ExecutionContextLogOptions.Default);
ec.AddIssue(new Issue() { Type = IssueType.Warning, Message = "warning" }, ExecutionContextLogOptions.Default);
ec.AddIssue(new Issue() { Type = IssueType.Warning, Message = "warning" }, ExecutionContextLogOptions.Default);
ec.Complete();
// Assert.
jobServerQueue.Verify(x => x.QueueTimelineRecordUpdate(It.IsAny<Guid>(), It.Is<TimelineRecord>(t => t.ErrorCount == 15)), Times.AtLeastOnce);
jobServerQueue.Verify(x => x.QueueTimelineRecordUpdate(It.IsAny<Guid>(), It.Is<TimelineRecord>(t => t.WarningCount == 14)), Times.AtLeastOnce);
jobServerQueue.Verify(x => x.QueueTimelineRecordUpdate(It.IsAny<Guid>(), It.Is<TimelineRecord>(t => t.ErrorCount > 0)), Times.AtLeast(10));
jobServerQueue.Verify(x => x.QueueTimelineRecordUpdate(It.IsAny<Guid>(), It.Is<TimelineRecord>(t => t.WarningCount > 0)), Times.AtLeast(10));
jobServerQueue.Verify(x => x.QueueTimelineRecordUpdate(It.IsAny<Guid>(), It.Is<TimelineRecord>(t => t.Issues.Where(i => i.Type == IssueType.Error).Count() == 10)), Times.AtLeastOnce);
jobServerQueue.Verify(x => x.QueueTimelineRecordUpdate(It.IsAny<Guid>(), It.Is<TimelineRecord>(t => t.Issues.Where(i => i.Type == IssueType.Warning).Count() == 10)), Times.AtLeastOnce);
}
@@ -190,9 +191,9 @@ namespace GitHub.Runner.Common.Tests.Worker
bigMessage += "a";
}
ec.AddIssue(new Issue() { Type = IssueType.Error, Message = bigMessage });
ec.AddIssue(new Issue() { Type = IssueType.Warning, Message = bigMessage });
ec.AddIssue(new Issue() { Type = IssueType.Notice, Message = bigMessage });
ec.AddIssue(new Issue() { Type = IssueType.Error, Message = bigMessage }, ExecutionContextLogOptions.Default);
ec.AddIssue(new Issue() { Type = IssueType.Warning, Message = bigMessage }, ExecutionContextLogOptions.Default);
ec.AddIssue(new Issue() { Type = IssueType.Notice, Message = bigMessage }, ExecutionContextLogOptions.Default);
ec.Complete();
@@ -203,6 +204,61 @@ namespace GitHub.Runner.Common.Tests.Worker
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void AddIssue_OverrideLogMessage()
{
using (TestHostContext hc = CreateTestContext())
{
// Arrange: Create a job request message.
TaskOrchestrationPlanReference plan = new();
TimelineReference timeline = new();
Guid jobId = Guid.NewGuid();
string jobName = "some job name";
var jobRequest = new Pipelines.AgentJobRequestMessage(plan, timeline, jobId, jobName, jobName, null, null, null, new Dictionary<string, VariableValue>(), new List<MaskHint>(), new Pipelines.JobResources(), new Pipelines.ContextData.DictionaryContextData(), new Pipelines.WorkspaceOptions(), new List<Pipelines.ActionStep>(), null, null, null, null);
jobRequest.Resources.Repositories.Add(new Pipelines.RepositoryResource()
{
Alias = Pipelines.PipelineConstants.SelfAlias,
Id = "github",
Version = "sha1"
});
jobRequest.ContextData["github"] = new Pipelines.ContextData.DictionaryContextData();
// Arrange: Setup the paging logger.
var pagingLogger = new Mock<IPagingLogger>();
var jobServerQueue = new Mock<IJobServerQueue>();
hc.EnqueueInstance(pagingLogger.Object);
hc.SetSingleton(jobServerQueue.Object);
var ec = new Runner.Worker.ExecutionContext();
ec.Initialize(hc);
// Act.
ec.InitializeJob(jobRequest, CancellationToken.None);
var issueMessage = "Message embedded in issue.";
var overrideMessage = "Message override.";
var options = new ExecutionContextLogOptions(true, overrideMessage);
ec.AddIssue(new Issue() { Type = IssueType.Error, Message = issueMessage }, options);
ec.AddIssue(new Issue() { Type = IssueType.Warning, Message = issueMessage }, options);
ec.AddIssue(new Issue() { Type = IssueType.Notice, Message = issueMessage }, options);
// Finally, add a variation that DOESN'T override the message.
ec.AddIssue(new Issue() { Type = IssueType.Notice, Message = issueMessage }, ExecutionContextLogOptions.Default);
ec.Complete();
// Assert.
jobServerQueue.Verify(x => x.QueueWebConsoleLine(It.IsAny<Guid>(), It.IsAny<string>(), It.IsAny<long?>()), Times.Exactly(4));
jobServerQueue.Verify(x => x.QueueWebConsoleLine(It.IsAny<Guid>(), It.Is<string>(text => text.EndsWith(overrideMessage)), It.IsAny<long?>()), Times.Exactly(3));
jobServerQueue.Verify(x => x.QueueWebConsoleLine(It.IsAny<Guid>(), It.Is<string>(text => text.EndsWith(issueMessage)), It.IsAny<long?>()), Times.Exactly(1));
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
@@ -242,13 +298,15 @@ namespace GitHub.Runner.Common.Tests.Worker
var embeddedStep = ec.CreateChild(Guid.NewGuid(), "action_1_pre", "action_1_pre", null, null, ActionRunStage.Main, isEmbedded: true);
embeddedStep.Start();
embeddedStep.AddIssue(new Issue() { Type = IssueType.Error, Message = "error annotation that should have step and line number information" });
embeddedStep.AddIssue(new Issue() { Type = IssueType.Warning, Message = "warning annotation that should have step and line number information" });
embeddedStep.AddIssue(new Issue() { Type = IssueType.Notice, Message = "notice annotation that should have step and line number information" });
embeddedStep.AddIssue(new Issue() { Type = IssueType.Error, Message = "error annotation that should have step and line number information" }, ExecutionContextLogOptions.Default);
embeddedStep.AddIssue(new Issue() { Type = IssueType.Warning, Message = "warning annotation that should have step and line number information" }, ExecutionContextLogOptions.Default);
embeddedStep.AddIssue(new Issue() { Type = IssueType.Notice, Message = "notice annotation that should have step and line number information" }, ExecutionContextLogOptions.Default);
jobServerQueue.Verify(x => x.QueueTimelineRecordUpdate(It.IsAny<Guid>(), It.Is<TimelineRecord>(t => t.Issues.Where(i => i.Data.ContainsKey("stepNumber") && i.Data.ContainsKey("logFileLineNumber") && i.Type == IssueType.Error).Count() == 1)), Times.AtLeastOnce);
jobServerQueue.Verify(x => x.QueueTimelineRecordUpdate(It.IsAny<Guid>(), It.Is<TimelineRecord>(t => t.Issues.Where(i => i.Data.ContainsKey("stepNumber") && i.Data.ContainsKey("logFileLineNumber") && i.Type == IssueType.Warning).Count() == 1)), Times.AtLeastOnce);
jobServerQueue.Verify(x => x.QueueTimelineRecordUpdate(It.IsAny<Guid>(), It.Is<TimelineRecord>(t => t.Issues.Where(i => i.Data.ContainsKey("stepNumber") && i.Data.ContainsKey("logFileLineNumber") && i.Type == IssueType.Notice).Count() == 1)), Times.AtLeastOnce);
jobServerQueue.Verify(x => x.QueueTimelineRecordUpdate(It.IsAny<Guid>(), It.IsAny<TimelineRecord>()), Times.AtLeastOnce);
// Verify that Error/Warning/Notice issues added to embedded steps don't get sent up to the server.
jobServerQueue.Verify(x => x.QueueTimelineRecordUpdate(It.IsAny<Guid>(), It.Is<TimelineRecord>(t => t.Issues.Where(i => i.Data.ContainsKey("stepNumber") && i.Data.ContainsKey("logFileLineNumber") && i.Type == IssueType.Error).Count() == 1)), Times.Never);
jobServerQueue.Verify(x => x.QueueTimelineRecordUpdate(It.IsAny<Guid>(), It.Is<TimelineRecord>(t => t.Issues.Where(i => i.Data.ContainsKey("stepNumber") && i.Data.ContainsKey("logFileLineNumber") && i.Type == IssueType.Warning).Count() == 1)), Times.Never);
jobServerQueue.Verify(x => x.QueueTimelineRecordUpdate(It.IsAny<Guid>(), It.Is<TimelineRecord>(t => t.Issues.Where(i => i.Data.ContainsKey("stepNumber") && i.Data.ContainsKey("logFileLineNumber") && i.Type == IssueType.Notice).Count() == 1)), Times.Never);
}
}
@@ -626,12 +684,12 @@ namespace GitHub.Runner.Common.Tests.Worker
ec.StepTelemetry.StepId = Guid.NewGuid();
ec.StepTelemetry.Stage = "main";
ec.AddIssue(new Issue() { Type = IssueType.Error, Message = "error" });
ec.AddIssue(new Issue() { Type = IssueType.Warning, Message = "warning" });
ec.AddIssue(new Issue() { Type = IssueType.Notice, Message = "notice" });
ec.AddIssue(new Issue() { Type = IssueType.Error, Message = "error" });
ec.AddIssue(new Issue() { Type = IssueType.Warning, Message = "warning" });
ec.AddIssue(new Issue() { Type = IssueType.Notice, Message = "notice" });
ec.AddIssue(new Issue() { Type = IssueType.Error, Message = "error" }, ExecutionContextLogOptions.Default);
ec.AddIssue(new Issue() { Type = IssueType.Warning, Message = "warning" }, ExecutionContextLogOptions.Default);
ec.AddIssue(new Issue() { Type = IssueType.Notice, Message = "notice" }, ExecutionContextLogOptions.Default);
ec.AddIssue(new Issue() { Type = IssueType.Error, Message = "error" }, ExecutionContextLogOptions.Default);
ec.AddIssue(new Issue() { Type = IssueType.Warning, Message = "warning" }, ExecutionContextLogOptions.Default);
ec.AddIssue(new Issue() { Type = IssueType.Notice, Message = "notice" }, ExecutionContextLogOptions.Default);
ec.Complete();
@@ -692,9 +750,9 @@ namespace GitHub.Runner.Common.Tests.Worker
embeddedStep.StepTelemetry.Action = "actions/checkout";
embeddedStep.StepTelemetry.Ref = "v2";
embeddedStep.AddIssue(new Issue() { Type = IssueType.Error, Message = "error" });
embeddedStep.AddIssue(new Issue() { Type = IssueType.Warning, Message = "warning" });
embeddedStep.AddIssue(new Issue() { Type = IssueType.Notice, Message = "notice" });
embeddedStep.AddIssue(new Issue() { Type = IssueType.Error, Message = "error" }, ExecutionContextLogOptions.Default);
embeddedStep.AddIssue(new Issue() { Type = IssueType.Warning, Message = "warning" }, ExecutionContextLogOptions.Default);
embeddedStep.AddIssue(new Issue() { Type = IssueType.Notice, Message = "notice" }, ExecutionContextLogOptions.Default);
embeddedStep.PublishStepTelemetry();
@@ -756,9 +814,9 @@ namespace GitHub.Runner.Common.Tests.Worker
embeddedStep.StepTelemetry.Action = "actions/checkout";
embeddedStep.StepTelemetry.Ref = "v2";
embeddedStep.AddIssue(new Issue() { Type = IssueType.Error, Message = "error" });
embeddedStep.AddIssue(new Issue() { Type = IssueType.Warning, Message = "warning" });
embeddedStep.AddIssue(new Issue() { Type = IssueType.Notice, Message = "notice" });
embeddedStep.AddIssue(new Issue() { Type = IssueType.Error, Message = "error" }, ExecutionContextLogOptions.Default);
embeddedStep.AddIssue(new Issue() { Type = IssueType.Warning, Message = "warning" }, ExecutionContextLogOptions.Default);
embeddedStep.AddIssue(new Issue() { Type = IssueType.Notice, Message = "notice" }, ExecutionContextLogOptions.Default);
ec.Complete();
@@ -927,7 +985,7 @@ namespace GitHub.Runner.Common.Tests.Worker
inputVarsContext["VARIABLE_2"] = new StringContextData("value2");
jobRequest.ContextData["vars"] = inputVarsContext;
// Arrange: Setup the paging logger.
// Arrange: Setup the paging logger.
var pagingLogger1 = new Mock<IPagingLogger>();
var jobServerQueue = new Mock<IJobServerQueue>();
hc.EnqueueInstance(pagingLogger1.Object);
@@ -941,7 +999,7 @@ namespace GitHub.Runner.Common.Tests.Worker
var expected = new DictionaryContextData();
expected["VARIABLE_1"] = new StringContextData("value1");
expected["VARIABLE_2"] = new StringContextData("value1");
Assert.True(ExpressionValuesAssertEqual(expected, jobContext.ExpressionValues["vars"] as DictionaryContextData));
}
}
@@ -972,7 +1030,7 @@ namespace GitHub.Runner.Common.Tests.Worker
inputVarsContext[Constants.Variables.Actions.RunnerDebug] = new StringContextData("true");
jobRequest.ContextData["vars"] = inputVarsContext;
// Arrange: Setup the paging logger.
// Arrange: Setup the paging logger.
var pagingLogger1 = new Mock<IPagingLogger>();
var jobServerQueue = new Mock<IJobServerQueue>();
hc.EnqueueInstance(pagingLogger1.Object);
@@ -983,7 +1041,7 @@ namespace GitHub.Runner.Common.Tests.Worker
jobContext.InitializeJob(jobRequest, CancellationToken.None);
Assert.Equal("true", jobContext.Global.Variables.Get(Constants.Variables.Actions.StepDebug));
Assert.Equal("true", jobContext.Global.Variables.Get(Constants.Variables.Actions.RunnerDebug));
}
@@ -1018,7 +1076,7 @@ namespace GitHub.Runner.Common.Tests.Worker
jobRequest.Variables[Constants.Variables.Actions.StepDebug] = "false";
jobRequest.Variables[Constants.Variables.Actions.RunnerDebug] = "false";
// Arrange: Setup the paging logger.
// Arrange: Setup the paging logger.
var pagingLogger1 = new Mock<IPagingLogger>();
var jobServerQueue = new Mock<IJobServerQueue>();
hc.EnqueueInstance(pagingLogger1.Object);
@@ -1029,7 +1087,7 @@ namespace GitHub.Runner.Common.Tests.Worker
jobContext.InitializeJob(jobRequest, CancellationToken.None);
Assert.Equal("false", jobContext.Global.Variables.Get(Constants.Variables.Actions.StepDebug));
Assert.Equal("false", jobContext.Global.Variables.Get(Constants.Variables.Actions.RunnerDebug));
}
@@ -1061,4 +1119,4 @@ namespace GitHub.Runner.Common.Tests.Worker
return true;
}
}
}
}

View File

@@ -984,10 +984,15 @@ namespace GitHub.Runner.Common.Tests.Worker
{
_onMatcherChanged = handler;
});
_executionContext.Setup(x => x.AddIssue(It.IsAny<DTWebApi.Issue>(), It.IsAny<string>()))
.Callback((DTWebApi.Issue issue, string logMessage) =>
_executionContext.Setup(x => x.AddIssue(It.IsAny<DTWebApi.Issue>(), It.IsAny<ExecutionContextLogOptions>()))
.Callback((DTWebApi.Issue issue, ExecutionContextLogOptions logOptions) =>
{
_issues.Add(new Tuple<DTWebApi.Issue, string>(issue, logMessage));
var resolvedMessage = issue.Message;
if (logOptions.WriteToLog && !string.IsNullOrEmpty(logOptions.LogMessageOverride))
{
resolvedMessage = logOptions.LogMessageOverride;
}
_issues.Add(new(issue, resolvedMessage));
});
_executionContext.Setup(x => x.Write(It.IsAny<string>(), It.IsAny<string>()))
.Callback((string tag, string message) =>

View File

@@ -413,12 +413,16 @@ namespace GitHub.Runner.Common.Tests.Worker
EnvironmentVariables = new Dictionary<string, string>(VarUtil.EnvironmentVariableKeyComparer),
WriteDebug = true,
});
_executionContext.Setup(x => x.AddIssue(It.IsAny<DTWebApi.Issue>(), It.IsAny<string>()))
.Callback((DTWebApi.Issue issue, string logMessage) =>
_executionContext.Setup(x => x.AddIssue(It.IsAny<DTWebApi.Issue>(), It.IsAny<ExecutionContextLogOptions>()))
.Callback((DTWebApi.Issue issue, ExecutionContextLogOptions logOptions) =>
{
_issues.Add(new Tuple<DTWebApi.Issue, string>(issue, logMessage));
var message = !string.IsNullOrEmpty(logMessage) ? logMessage : issue.Message;
_trace.Info($"Issue '{issue.Type}': {message}");
var resolvedMessage = issue.Message;
if (logOptions.WriteToLog && !string.IsNullOrEmpty(logOptions.LogMessageOverride))
{
resolvedMessage = logOptions.LogMessageOverride;
}
_issues.Add(new(issue, resolvedMessage));
_trace.Info($"Issue '{issue.Type}': {resolvedMessage}");
});
_executionContext.Setup(x => x.Write(It.IsAny<string>(), It.IsAny<string>()))
.Callback((string tag, string message) =>

View File

@@ -411,12 +411,16 @@ namespace GitHub.Runner.Common.Tests.Worker
EnvironmentVariables = new Dictionary<string, string>(VarUtil.EnvironmentVariableKeyComparer),
WriteDebug = true,
});
_executionContext.Setup(x => x.AddIssue(It.IsAny<DTWebApi.Issue>(), It.IsAny<string>()))
.Callback((DTWebApi.Issue issue, string logMessage) =>
_executionContext.Setup(x => x.AddIssue(It.IsAny<DTWebApi.Issue>(), It.IsAny<ExecutionContextLogOptions>()))
.Callback((DTWebApi.Issue issue, ExecutionContextLogOptions logOptions) =>
{
_issues.Add(new Tuple<DTWebApi.Issue, string>(issue, logMessage));
var message = !string.IsNullOrEmpty(logMessage) ? logMessage : issue.Message;
_trace.Info($"Issue '{issue.Type}': {message}");
var resolvedMessage = issue.Message;
if (logOptions.WriteToLog && !string.IsNullOrEmpty(logOptions.LogMessageOverride))
{
resolvedMessage = logOptions.LogMessageOverride;
}
_issues.Add(new(issue, resolvedMessage));
_trace.Info($"Issue '{issue.Type}': {resolvedMessage}");
});
_executionContext.Setup(x => x.Write(It.IsAny<string>(), It.IsAny<string>()))
.Callback((string tag, string message) =>

View File

@@ -413,12 +413,16 @@ namespace GitHub.Runner.Common.Tests.Worker
EnvironmentVariables = new Dictionary<string, string>(VarUtil.EnvironmentVariableKeyComparer),
WriteDebug = true,
});
_executionContext.Setup(x => x.AddIssue(It.IsAny<DTWebApi.Issue>(), It.IsAny<string>()))
.Callback((DTWebApi.Issue issue, string logMessage) =>
_executionContext.Setup(x => x.AddIssue(It.IsAny<DTWebApi.Issue>(), It.IsAny<ExecutionContextLogOptions>()))
.Callback((DTWebApi.Issue issue, ExecutionContextLogOptions logOptions) =>
{
_issues.Add(new Tuple<DTWebApi.Issue, string>(issue, logMessage));
var message = !string.IsNullOrEmpty(logMessage) ? logMessage : issue.Message;
_trace.Info($"Issue '{issue.Type}': {message}");
var resolvedMessage = issue.Message;
if (logOptions.WriteToLog && !string.IsNullOrEmpty(logOptions.LogMessageOverride))
{
resolvedMessage = logOptions.LogMessageOverride;
}
_issues.Add(new(issue, resolvedMessage));
_trace.Info($"Issue '{issue.Type}': {resolvedMessage}");
});
_executionContext.Setup(x => x.Write(It.IsAny<string>(), It.IsAny<string>()))
.Callback((string tag, string message) =>
@@ -430,8 +434,8 @@ namespace GitHub.Runner.Common.Tests.Worker
_executionContext.Setup(x => x.SetOutput(It.IsAny<string>(), It.IsAny<string>(), out reference))
.Callback((string name, string value, out string reference) =>
{
reference = value;
_outputs[name] = value;
reference = value;
_outputs[name] = value;
});
// SetOutputFileCommand

View File

@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
@@ -337,7 +337,7 @@ namespace GitHub.Runner.Common.Tests.Worker
// Act.
await _stepsRunner.RunAsync(jobContext: _ec.Object);
// Assert.
// Assert.
Assert.Equal(2, variableSet.Step.Length);
variableSet.Step[0].Verify(x => x.RunAsync());
variableSet.Step[1].Verify(x => x.RunAsync(), variableSet.Expected ? Times.Once() : Times.Never());
@@ -590,7 +590,7 @@ namespace GitHub.Runner.Common.Tests.Worker
step.Setup(x => x.Condition).Returns(condition);
step.Setup(x => x.ContinueOnError).Returns(new BooleanToken(null, null, null, continueOnError));
step.Setup(x => x.Action)
.Returns(new DistributedTask.Pipelines.ActionStep()
.Returns(new GitHub.DistributedTask.Pipelines.ActionStep()
{
Name = name,
Id = Guid.NewGuid(),

View File

@@ -0,0 +1,70 @@
minimal: |
{ "Name": "minimal" }
invalid-attempt-value: |
{
"Name": "invalid-attempt-value",
"Attempt": -99
}
zero-attempt-value: |
{
"Name": "zero-attempt-value",
"Attempt": 0
}
legacy-nulls: |
{
"Name": "legacy-nulls",
"ErrorCount": null,
"WarningCount": null,
"NoticeCount": null
}
missing-counts: |
{
"Name": "missing-counts"
}
non-zero-counts: |
{
"Name": "non-zero-counts",
"ErrorCount": 10,
"WarningCount": 20,
"NoticeCount": 30
}
explicit-null-collections: |
{
"Name": "explicit-null-collections",
"Issues": null,
"PreviousAttempts": null,
"Variables": null
}
lean: |
{
"Id": "00000000-0000-0000-0000-000000000000",
"Name": "lean",
"LastModified": "\/Date(1679073003252+0000)\/",
"Issues": [
{
"Type": 0,
"Category": null,
"Message": null,
"IsInfrastructureIssue": null
}
],
"Variables": [
{ "Key": "x", "Value": { "Value": "1" } },
{ "Key": "y", "Value": { "Value": "2" } },
{ "Key": "z", "Value": { "Value": "3" } }
],
"Attempt": 4,
"PreviousAttempts": [
{ "Attempt": 1 },
{ "Attempt": 2 },
{ "Attempt": 3 }
]
}
duplicate-variable-keys: |
{
"Name": "duplicate-variable-keys",
"Variables": [
{ "Key": "aaa", "Value": { "Value": "a.1" } },
{ "Key": "AAA", "Value": { "Value": "a.2" } }
]
}