diff --git a/src/Runner.Worker/ActionManifestManagerWrapper.cs b/src/Runner.Worker/ActionManifestManagerWrapper.cs index 974eac7dc..6d893fd82 100644 --- a/src/Runner.Worker/ActionManifestManagerWrapper.cs +++ b/src/Runner.Worker/ActionManifestManagerWrapper.cs @@ -775,6 +775,14 @@ namespace GitHub.Runner.Worker return false; } + // Check for known equivalent error patterns (e.g., JSON parse errors) + // where both parsers correctly reject invalid input but with different wording + if (PipelineTemplateEvaluatorWrapper.HasJsonExceptionType(legacyException) && PipelineTemplateEvaluatorWrapper.HasJsonExceptionType(newException)) + { + trace.Info("CompareExceptions - both exceptions are JSON parse errors, treating as matched"); + return true; + } + // Compare exception messages recursively (including inner exceptions) var legacyMessages = GetExceptionMessages(legacyException); var newMessages = GetExceptionMessages(newException); @@ -839,5 +847,6 @@ namespace GitHub.Runner.Worker return messages; } + } } diff --git a/src/Runner.Worker/PipelineTemplateEvaluatorWrapper.cs b/src/Runner.Worker/PipelineTemplateEvaluatorWrapper.cs index ae7ebc1c8..61dfdacce 100644 --- a/src/Runner.Worker/PipelineTemplateEvaluatorWrapper.cs +++ b/src/Runner.Worker/PipelineTemplateEvaluatorWrapper.cs @@ -216,7 +216,7 @@ namespace GitHub.Runner.Worker } } - private TLegacy EvaluateAndCompare( + internal TLegacy EvaluateAndCompare( string methodName, Func legacyEvaluator, Func newEvaluator, @@ -668,18 +668,14 @@ namespace GitHub.Runner.Worker /// private bool IsKnownEquivalentErrorPattern(Exception legacyException, Exception newException) { - // Get all messages in the exception chain - var legacyMessages = string.Join(" | ", GetExceptionMessages(legacyException)); - var newMessages = string.Join(" | ", GetExceptionMessages(newException)); - // fromJSON('') - both parsers fail when parsing empty string as JSON // The error messages differ but both indicate JSON parsing failure. // Legacy throws raw JsonReaderException: "Error reading JToken from JsonReader..." // New wraps it: "Error parsing fromJson" with inner JsonReaderException // Both may be wrapped in TemplateValidationException: "The template is not valid..." - if (IsJsonParseError(legacyMessages) && IsJsonParseError(newMessages)) + if (HasJsonExceptionType(legacyException) && HasJsonExceptionType(newException)) { - _trace.Info("CompareExceptions - both exceptions are JSON parse errors (semantically equivalent)"); + _trace.Info("CompareExceptions - both exceptions are JSON parse errors, treating as matched"); return true; } @@ -687,14 +683,44 @@ namespace GitHub.Runner.Worker } /// - /// Checks if the exception message chain indicates a JSON parsing error. + /// Checks if the exception chain contains a JSON-related exception type. /// - private bool IsJsonParseError(string messages) + internal static bool HasJsonExceptionType(Exception ex) { - // Common patterns for JSON parse errors from fromJSON function - return messages.Contains("Error reading JToken from JsonReader") || - messages.Contains("Error parsing fromJson") || - messages.Contains("JsonReaderException"); + var toProcess = new Queue(); + toProcess.Enqueue(ex); + int count = 0; + + while (toProcess.Count > 0 && count < 50) + { + var current = toProcess.Dequeue(); + if (current == null) continue; + + count++; + + if (current is Newtonsoft.Json.JsonReaderException || + current is System.Text.Json.JsonException) + { + return true; + } + + if (current is AggregateException aggregateEx) + { + foreach (var innerEx in aggregateEx.InnerExceptions) + { + if (innerEx != null && count < 50) + { + toProcess.Enqueue(innerEx); + } + } + } + else if (current.InnerException != null) + { + toProcess.Enqueue(current.InnerException); + } + } + + return false; } private IList GetExceptionMessages(Exception ex) diff --git a/src/Test/L0/Worker/ActionManifestParserComparisonL0.cs b/src/Test/L0/Worker/ActionManifestParserComparisonL0.cs index 26231d7cc..d7039767c 100644 --- a/src/Test/L0/Worker/ActionManifestParserComparisonL0.cs +++ b/src/Test/L0/Worker/ActionManifestParserComparisonL0.cs @@ -222,41 +222,37 @@ namespace GitHub.Runner.Common.Tests.Worker [Fact] [Trait("Level", "L0")] [Trait("Category", "Worker")] - public void EvaluateAndCompare_SkipsMismatchRecording_WhenCancellationOccursDuringEvaluation() + public void EvaluateDefaultInput_BothParsersAgree() { try { - // Arrange - Test that mismatches are not recorded when cancellation state changes during evaluation Setup(); - - // Enable comparison feature _ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true"); - // Create the wrapper - var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object); + var legacyManager = new ActionManifestManagerLegacy(); + legacyManager.Initialize(_hc); + _hc.SetSingleton(legacyManager); - // Create a simple token for evaluation - var token = new StringToken(null, null, null, "test-value"); - var contextData = new DictionaryContextData(); - var expressionFunctions = new List(); + var newManager = new ActionManifestManager(); + newManager.Initialize(_hc); + _hc.SetSingleton(newManager); - // First evaluation without cancellation - should work normally - var result1 = wrapper.EvaluateStepDisplayName(token, contextData, expressionFunctions); - Assert.Equal("test-value", result1); - Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch); + var wrapper = new ActionManifestManagerWrapper(); + wrapper.Initialize(_hc); - // Now simulate a scenario where cancellation occurs during evaluation - // Cancel the token before next evaluation - _ecTokenSource.Cancel(); + _ec.Object.ExpressionValues["github"] = new LegacyContextData.DictionaryContextData(); + _ec.Object.ExpressionValues["strategy"] = new LegacyContextData.DictionaryContextData(); + _ec.Object.ExpressionValues["matrix"] = new LegacyContextData.DictionaryContextData(); + _ec.Object.ExpressionValues["steps"] = new LegacyContextData.DictionaryContextData(); + _ec.Object.ExpressionValues["job"] = new LegacyContextData.DictionaryContextData(); + _ec.Object.ExpressionValues["runner"] = new LegacyContextData.DictionaryContextData(); + _ec.Object.ExpressionValues["env"] = new LegacyContextData.DictionaryContextData(); + _ec.Object.ExpressionFunctions.Add(new LegacyExpressions.FunctionInfo("hashFiles", 1, 255)); - // Evaluate again - even if there were a mismatch, it should be skipped due to cancellation - var result2 = wrapper.EvaluateStepDisplayName(token, contextData, expressionFunctions); - Assert.Equal("test-value", result2); + var result = wrapper.EvaluateDefaultInput(_ec.Object, "testInput", new StringToken(null, null, null, "defaultValue")); - // Verify no mismatch was recorded (cancellation race detection should have prevented it) - // Note: In this test, both parsers return the same result, so there's no actual mismatch. - // The cancellation race detection is a safeguard for when results differ due to timing. - Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch); + Assert.Equal("defaultValue", result); + Assert.False(_ec.Object.Global.HasActionManifestMismatch); } finally { @@ -267,30 +263,115 @@ namespace GitHub.Runner.Common.Tests.Worker [Fact] [Trait("Level", "L0")] [Trait("Category", "Worker")] - public void EvaluateAndCompare_DoesNotRecordMismatch_WhenResultsMatch() + public void EvaluateContainerArguments_BothParsersAgree() { try { - // Arrange - Test that no mismatch is recorded when both parsers return matching results Setup(); - - // Enable comparison feature _ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true"); - // Create the wrapper - var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object); + var legacyManager = new ActionManifestManagerLegacy(); + legacyManager.Initialize(_hc); + _hc.SetSingleton(legacyManager); - // Create a simple token for evaluation - var token = new StringToken(null, null, null, "test-value"); - var contextData = new DictionaryContextData(); - var expressionFunctions = new List(); + var newManager = new ActionManifestManager(); + newManager.Initialize(_hc); + _hc.SetSingleton(newManager); - // Evaluation without cancellation - should work normally and not record mismatch for matching results - var result = wrapper.EvaluateStepDisplayName(token, contextData, expressionFunctions); - Assert.Equal("test-value", result); + var wrapper = new ActionManifestManagerWrapper(); + wrapper.Initialize(_hc); - // Since both parsers return the same result, no mismatch should be recorded - Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch); + var arguments = new SequenceToken(null, null, null); + arguments.Add(new StringToken(null, null, null, "arg1")); + arguments.Add(new StringToken(null, null, null, "arg2")); + + var evaluateContext = new Dictionary(StringComparer.OrdinalIgnoreCase); + + var result = wrapper.EvaluateContainerArguments(_ec.Object, arguments, evaluateContext); + + Assert.Equal(2, result.Count); + Assert.Equal("arg1", result[0]); + Assert.Equal("arg2", result[1]); + Assert.False(_ec.Object.Global.HasActionManifestMismatch); + } + finally + { + Teardown(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void EvaluateContainerEnvironment_BothParsersAgree() + { + try + { + Setup(); + _ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true"); + + var legacyManager = new ActionManifestManagerLegacy(); + legacyManager.Initialize(_hc); + _hc.SetSingleton(legacyManager); + + var newManager = new ActionManifestManager(); + newManager.Initialize(_hc); + _hc.SetSingleton(newManager); + + var wrapper = new ActionManifestManagerWrapper(); + wrapper.Initialize(_hc); + + var environment = new MappingToken(null, null, null); + environment.Add(new StringToken(null, null, null, "hello"), new StringToken(null, null, null, "world")); + + var evaluateContext = new Dictionary(StringComparer.OrdinalIgnoreCase); + + var result = wrapper.EvaluateContainerEnvironment(_ec.Object, environment, evaluateContext); + + Assert.Equal(1, result.Count); + Assert.Equal("world", result["hello"]); + Assert.False(_ec.Object.Global.HasActionManifestMismatch); + } + finally + { + Teardown(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void EvaluateCompositeOutputs_BothParsersAgree() + { + try + { + Setup(); + _ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true"); + + var legacyManager = new ActionManifestManagerLegacy(); + legacyManager.Initialize(_hc); + _hc.SetSingleton(legacyManager); + + var newManager = new ActionManifestManager(); + newManager.Initialize(_hc); + _hc.SetSingleton(newManager); + + var wrapper = new ActionManifestManagerWrapper(); + wrapper.Initialize(_hc); + + var outputDef = new MappingToken(null, null, null); + outputDef.Add(new StringToken(null, null, null, "description"), new StringToken(null, null, null, "test output")); + outputDef.Add(new StringToken(null, null, null, "value"), new StringToken(null, null, null, "value1")); + + var token = new MappingToken(null, null, null); + token.Add(new StringToken(null, null, null, "output1"), outputDef); + + var evaluateContext = new Dictionary(StringComparer.OrdinalIgnoreCase); + + var result = wrapper.EvaluateCompositeOutputs(_ec.Object, token, evaluateContext); + + Assert.NotNull(result); + Assert.False(_ec.Object.Global.HasActionManifestMismatch); } finally { diff --git a/src/Test/L0/Worker/PipelineTemplateEvaluatorWrapperL0.cs b/src/Test/L0/Worker/PipelineTemplateEvaluatorWrapperL0.cs new file mode 100644 index 000000000..2caf5dba0 --- /dev/null +++ b/src/Test/L0/Worker/PipelineTemplateEvaluatorWrapperL0.cs @@ -0,0 +1,550 @@ +using GitHub.DistributedTask.ObjectTemplating.Tokens; +using GitHub.DistributedTask.Pipelines.ContextData; +using GitHub.DistributedTask.Pipelines.ObjectTemplating; +using GitHub.DistributedTask.WebApi; +using GitHub.Runner.Common; +using GitHub.Runner.Sdk; +using GitHub.Runner.Worker; +using LegacyContextData = GitHub.DistributedTask.Pipelines.ContextData; +using LegacyExpressions = GitHub.DistributedTask.Expressions2; +using Moq; +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Threading; +using Xunit; + +namespace GitHub.Runner.Common.Tests.Worker +{ + public sealed class PipelineTemplateEvaluatorWrapperL0 + { + private CancellationTokenSource _ecTokenSource; + private Mock _ec; + private TestHostContext _hc; + + // ------------------------------------------------------------------- + // EvaluateAndCompare core behavior + // ------------------------------------------------------------------- + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void EvaluateAndCompare_DoesNotRecordMismatch_WhenResultsMatch() + { + try + { + Setup(); + _ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true"); + + var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object); + + var token = new StringToken(null, null, null, "test-value"); + var contextData = new DictionaryContextData(); + var expressionFunctions = new List(); + + var result = wrapper.EvaluateStepDisplayName(token, contextData, expressionFunctions); + + Assert.Equal("test-value", result); + Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch); + } + finally + { + Teardown(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void EvaluateAndCompare_SkipsMismatchRecording_WhenCancellationOccursDuringEvaluation() + { + try + { + Setup(); + _ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true"); + + var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object); + + // Call EvaluateAndCompare directly: the new evaluator cancels the token + // and returns a different value, forcing hasMismatch = true. + // Because cancellation flipped during the evaluation window, the + // mismatch should be skipped. + var result = wrapper.EvaluateAndCompare( + "TestCancellationSkip", + () => "legacy-value", + () => + { + _ecTokenSource.Cancel(); + return "different-value"; + }, + (legacy, @new) => string.Equals(legacy, @new, StringComparison.Ordinal)); + + Assert.Equal("legacy-value", result); + Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch); + } + finally + { + Teardown(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void EvaluateAndCompare_RecordsMismatch_WhenResultsDifferWithoutCancellation() + { + try + { + Setup(); + _ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true"); + + var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object); + + // Different results without cancellation — mismatch SHOULD be recorded. + var result = wrapper.EvaluateAndCompare( + "TestMismatchRecorded", + () => "legacy-value", + () => "different-value", + (legacy, @new) => string.Equals(legacy, @new, StringComparison.Ordinal)); + + Assert.Equal("legacy-value", result); + Assert.True(_ec.Object.Global.HasTemplateEvaluatorMismatch); + } + finally + { + Teardown(); + } + } + + // ------------------------------------------------------------------- + // Smoke tests — both parsers agree, no mismatch recorded + // ------------------------------------------------------------------- + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void EvaluateStepContinueOnError_BothParsersAgree() + { + try + { + Setup(); + _ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true"); + + var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object); + var token = new BooleanToken(null, null, null, true); + var contextData = new DictionaryContextData(); + var functions = new List(); + + var result = wrapper.EvaluateStepContinueOnError(token, contextData, functions); + + Assert.True(result); + Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch); + } + finally + { + Teardown(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void EvaluateStepEnvironment_BothParsersAgree() + { + try + { + Setup(); + _ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true"); + + var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object); + var token = new MappingToken(null, null, null); + token.Add(new StringToken(null, null, null, "FOO"), new StringToken(null, null, null, "bar")); + var contextData = new DictionaryContextData(); + var functions = new List(); + + var result = wrapper.EvaluateStepEnvironment(token, contextData, functions, StringComparer.OrdinalIgnoreCase); + + Assert.NotNull(result); + Assert.Equal("bar", result["FOO"]); + Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch); + } + finally + { + Teardown(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void EvaluateStepIf_BothParsersAgree() + { + try + { + Setup(); + _ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true"); + + var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object); + var token = new BasicExpressionToken(null, null, null, "true"); + var contextData = new DictionaryContextData(); + var functions = new List(); + var expressionState = new List>(); + + var result = wrapper.EvaluateStepIf(token, contextData, functions, expressionState); + + Assert.True(result); + Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch); + } + finally + { + Teardown(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void EvaluateStepInputs_BothParsersAgree() + { + try + { + Setup(); + _ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true"); + + var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object); + var token = new MappingToken(null, null, null); + token.Add(new StringToken(null, null, null, "input1"), new StringToken(null, null, null, "val1")); + var contextData = new DictionaryContextData(); + var functions = new List(); + + var result = wrapper.EvaluateStepInputs(token, contextData, functions); + + Assert.NotNull(result); + Assert.Equal("val1", result["input1"]); + Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch); + } + finally + { + Teardown(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void EvaluateStepTimeout_BothParsersAgree() + { + try + { + Setup(); + _ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true"); + + var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object); + var token = new NumberToken(null, null, null, 10); + var contextData = new DictionaryContextData(); + var functions = new List(); + + var result = wrapper.EvaluateStepTimeout(token, contextData, functions); + + Assert.Equal(10, result); + Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch); + } + finally + { + Teardown(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void EvaluateJobContainer_EmptyImage_BothParsersAgree() + { + try + { + Setup(); + _ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true"); + + var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object); + var token = new StringToken(null, null, null, ""); + var contextData = new DictionaryContextData(); + var functions = new List(); + + var result = wrapper.EvaluateJobContainer(token, contextData, functions); + + Assert.Null(result); + Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch); + } + finally + { + Teardown(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void EvaluateJobOutput_BothParsersAgree() + { + try + { + Setup(); + _ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true"); + + var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object); + var token = new MappingToken(null, null, null); + token.Add(new StringToken(null, null, null, "out1"), new StringToken(null, null, null, "val1")); + var contextData = new DictionaryContextData(); + var functions = new List(); + + var result = wrapper.EvaluateJobOutput(token, contextData, functions); + + Assert.NotNull(result); + Assert.Equal("val1", result["out1"]); + Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch); + } + finally + { + Teardown(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void EvaluateEnvironmentUrl_BothParsersAgree() + { + try + { + Setup(); + _ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true"); + + var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object); + var token = new StringToken(null, null, null, "https://example.com"); + var contextData = new DictionaryContextData(); + var functions = new List(); + + var result = wrapper.EvaluateEnvironmentUrl(token, contextData, functions); + + Assert.NotNull(result); + var stringResult = result as StringToken; + Assert.NotNull(stringResult); + Assert.Equal("https://example.com", stringResult.Value); + Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch); + } + finally + { + Teardown(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void EvaluateJobDefaultsRun_BothParsersAgree() + { + try + { + Setup(); + _ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true"); + + var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object); + var token = new MappingToken(null, null, null); + token.Add(new StringToken(null, null, null, "shell"), new StringToken(null, null, null, "bash")); + var contextData = new DictionaryContextData(); + var functions = new List(); + + var result = wrapper.EvaluateJobDefaultsRun(token, contextData, functions); + + Assert.NotNull(result); + Assert.Equal("bash", result["shell"]); + Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch); + } + finally + { + Teardown(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void EvaluateJobServiceContainers_Null_BothParsersAgree() + { + try + { + Setup(); + _ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true"); + + var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object); + var contextData = new DictionaryContextData(); + var functions = new List(); + + var result = wrapper.EvaluateJobServiceContainers(null, contextData, functions); + + Assert.Null(result); + Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch); + } + finally + { + Teardown(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void EvaluateJobSnapshotRequest_Null_BothParsersAgree() + { + try + { + Setup(); + _ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true"); + + var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object); + var contextData = new DictionaryContextData(); + var functions = new List(); + + var result = wrapper.EvaluateJobSnapshotRequest(null, contextData, functions); + + Assert.Null(result); + Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch); + } + finally + { + Teardown(); + } + } + + // ------------------------------------------------------------------- + // JSON parse error equivalence via EvaluateAndCompare + // ------------------------------------------------------------------- + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void EvaluateAndCompare_JsonReaderExceptions_TreatedAsEquivalent() + { + try + { + Setup(); + _ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true"); + + var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object); + + // Both throw JsonReaderException with different messages — should be treated as equivalent + var legacyEx = new Newtonsoft.Json.JsonReaderException("Error reading JToken from JsonReader. Path '', line 0, position 0."); + var newEx = new Newtonsoft.Json.JsonReaderException("Error parsing fromJson", new Newtonsoft.Json.JsonReaderException("Unexpected end")); + + Assert.Throws(() => + wrapper.EvaluateAndCompare( + "TestJsonEquivalence", + () => throw legacyEx, + () => throw newEx, + (a, b) => string.Equals(a, b, StringComparison.Ordinal))); + + Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch); + } + finally + { + Teardown(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void EvaluateAndCompare_MixedJsonExceptionTypes_TreatedAsEquivalent() + { + try + { + Setup(); + _ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true"); + + var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object); + + // Legacy throws Newtonsoft JsonReaderException, new throws System.Text.Json.JsonException + var legacyEx = new Newtonsoft.Json.JsonReaderException("Error reading JToken"); + var newEx = new System.Text.Json.JsonException("Error parsing fromJson"); + + Assert.Throws(() => + wrapper.EvaluateAndCompare( + "TestMixedJsonTypes", + () => throw legacyEx, + () => throw newEx, + (a, b) => string.Equals(a, b, StringComparison.Ordinal))); + + Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch); + } + finally + { + Teardown(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void EvaluateAndCompare_NonJsonExceptions_RecordsMismatch() + { + try + { + Setup(); + _ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true"); + + var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object); + + // Both throw non-JSON exceptions with different messages — should record mismatch + var legacyEx = new InvalidOperationException("some error"); + var newEx = new InvalidOperationException("different error"); + + Assert.Throws(() => + wrapper.EvaluateAndCompare( + "TestNonJsonMismatch", + () => throw legacyEx, + () => throw newEx, + (a, b) => string.Equals(a, b, StringComparison.Ordinal))); + + Assert.True(_ec.Object.Global.HasTemplateEvaluatorMismatch); + } + finally + { + Teardown(); + } + } + + // ------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------- + + private void Setup([CallerMemberName] string name = "") + { + _ecTokenSource?.Dispose(); + _ecTokenSource = new CancellationTokenSource(); + + _hc = new TestHostContext(this, name); + + var expressionValues = new LegacyContextData.DictionaryContextData(); + var expressionFunctions = new List(); + + _ec = new Mock(); + _ec.Setup(x => x.Global) + .Returns(new GlobalContext + { + FileTable = new List(), + Variables = new Variables(_hc, new Dictionary()), + WriteDebug = true, + }); + _ec.Setup(x => x.CancellationToken).Returns(_ecTokenSource.Token); + _ec.Setup(x => x.ExpressionValues).Returns(expressionValues); + _ec.Setup(x => x.ExpressionFunctions).Returns(expressionFunctions); + _ec.Setup(x => x.Write(It.IsAny(), It.IsAny())).Callback((string tag, string message) => { _hc.GetTrace().Info($"{tag}{message}"); }); + _ec.Setup(x => x.AddIssue(It.IsAny(), It.IsAny())).Callback((Issue issue, ExecutionContextLogOptions logOptions) => { _hc.GetTrace().Info($"[{issue.Type}]{logOptions.LogMessageOverride ?? issue.Message}"); }); + } + + private void Teardown() + { + _hc?.Dispose(); + } + } +}