From 90da9fbb8cbcbe32293a090b3de4958aa2fcedcc Mon Sep 17 00:00:00 2001 From: eric sciple Date: Fri, 6 Feb 2026 16:17:00 +0000 Subject: [PATCH] Add CutoverWorkflowParser feature flag for workflow parser cutover Add a new feature flag (actions_runner_cutover_workflow_parser) that enables the wrapper classes to use only the new workflow parser/evaluator implementation while converting results back to legacy types for callers. Flag precedence: cutover > compare > legacy-only. Rename EvaluateAndCompare to EvaluateWrapper in both wrapper classes. --- src/Runner.Common/Constants.cs | 1 + .../ActionManifestManagerWrapper.cs | 29 +- src/Runner.Worker/ExecutionContext.cs | 3 +- src/Runner.Worker/InternalsVisibleTo.cs | 3 + .../PipelineTemplateEvaluatorWrapper.cs | 44 +- .../ActionManifestParserComparisonL0.cs | 456 ++++++++++++++++++ 6 files changed, 514 insertions(+), 22 deletions(-) create mode 100644 src/Runner.Worker/InternalsVisibleTo.cs create mode 100644 src/Test/L0/Worker/ActionManifestParserComparisonL0.cs diff --git a/src/Runner.Common/Constants.cs b/src/Runner.Common/Constants.cs index f2d5dd26ee5..53ece8e4428 100644 --- a/src/Runner.Common/Constants.cs +++ b/src/Runner.Common/Constants.cs @@ -172,6 +172,7 @@ public static class Features public static readonly string SnapshotPreflightHostedRunnerCheck = "actions_snapshot_preflight_hosted_runner_check"; public static readonly string SnapshotPreflightImageGenPoolCheck = "actions_snapshot_preflight_image_gen_pool_check"; public static readonly string CompareWorkflowParser = "actions_runner_compare_workflow_parser"; + public static readonly string CutoverWorkflowParser = "actions_runner_cutover_workflow_parser"; public static readonly string SetOrchestrationIdEnvForActions = "actions_set_orchestration_id_env_for_actions"; public static readonly string SendJobLevelAnnotations = "actions_send_job_level_annotations"; } diff --git a/src/Runner.Worker/ActionManifestManagerWrapper.cs b/src/Runner.Worker/ActionManifestManagerWrapper.cs index aa265dbf4b4..0811fa02948 100644 --- a/src/Runner.Worker/ActionManifestManagerWrapper.cs +++ b/src/Runner.Worker/ActionManifestManagerWrapper.cs @@ -40,7 +40,7 @@ public override void Initialize(IHostContext hostContext) public ActionDefinitionData Load(IExecutionContext executionContext, string manifestFile) { - return EvaluateAndCompare( + return EvaluateWrapper( executionContext, "Load", () => _legacyManager.Load(executionContext, manifestFile), @@ -53,7 +53,7 @@ public DictionaryContextData EvaluateCompositeOutputs( TemplateToken token, IDictionary extraExpressionValues) { - return EvaluateAndCompare( + return EvaluateWrapper( executionContext, "EvaluateCompositeOutputs", () => _legacyManager.EvaluateCompositeOutputs(executionContext, token, extraExpressionValues), @@ -66,7 +66,7 @@ public List EvaluateContainerArguments( SequenceToken token, IDictionary extraExpressionValues) { - return EvaluateAndCompare( + return EvaluateWrapper( executionContext, "EvaluateContainerArguments", () => _legacyManager.EvaluateContainerArguments(executionContext, token, extraExpressionValues), @@ -79,12 +79,13 @@ public Dictionary EvaluateContainerEnvironment( MappingToken token, IDictionary extraExpressionValues) { - return EvaluateAndCompare( + return EvaluateWrapper( executionContext, "EvaluateContainerEnvironment", () => _legacyManager.EvaluateContainerEnvironment(executionContext, token, extraExpressionValues), () => _newManager.EvaluateContainerEnvironment(executionContext, ConvertToNewToken(token) as GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens.MappingToken, ConvertToNewExpressionValues(extraExpressionValues)), - (legacyResult, newResult) => { + (legacyResult, newResult) => + { var trace = HostContext.GetTrace(nameof(ActionManifestManagerWrapper)); return CompareDictionaries(trace, legacyResult, newResult, "ContainerEnvironment"); }); @@ -95,7 +96,7 @@ public string EvaluateDefaultInput( string inputName, TemplateToken token) { - return EvaluateAndCompare( + return EvaluateWrapper( executionContext, "EvaluateDefaultInput", () => _legacyManager.EvaluateDefaultInput(executionContext, inputName, token), @@ -216,13 +217,27 @@ private T ConvertToLegacyContextData(GitHub.Actions.Expressions.Data.Expressi } // Comparison helper methods - private TLegacy EvaluateAndCompare( + private TLegacy EvaluateWrapper( IExecutionContext context, string methodName, Func legacyEvaluator, Func newEvaluator, Func resultComparer) { + // Cutover: use only the new evaluator, convert result to legacy type + if ((context.Global.Variables.GetBoolean(Constants.Runner.Features.CutoverWorkflowParser) ?? false) + || StringUtil.ConvertToBoolean(Environment.GetEnvironmentVariable("ACTIONS_RUNNER_CUTOVER_WORKFLOW_PARSER"))) + { + var newResult = newEvaluator(); + if (typeof(TLegacy) == typeof(TNew)) + { + return (TLegacy)(object)newResult; + } + + var json = StringUtil.ConvertToJson(newResult, Newtonsoft.Json.Formatting.None); + return StringUtil.ConvertFromJson(json); + } + // Legacy only? if (!((context.Global.Variables.GetBoolean(Constants.Runner.Features.CompareWorkflowParser) ?? false) || StringUtil.ConvertToBoolean(Environment.GetEnvironmentVariable("ACTIONS_RUNNER_COMPARE_WORKFLOW_PARSER")))) diff --git a/src/Runner.Worker/ExecutionContext.cs b/src/Runner.Worker/ExecutionContext.cs index 2a7cd11fb06..9b7feac09a6 100644 --- a/src/Runner.Worker/ExecutionContext.cs +++ b/src/Runner.Worker/ExecutionContext.cs @@ -1416,7 +1416,8 @@ public static IEnumerable> ToExpressionState(this I public static IPipelineTemplateEvaluator ToPipelineTemplateEvaluator(this IExecutionContext context, ObjectTemplating.ITraceWriter traceWriter = null) { // Create wrapper? - if ((context.Global.Variables.GetBoolean(Constants.Runner.Features.CompareWorkflowParser) ?? false) || StringUtil.ConvertToBoolean(Environment.GetEnvironmentVariable("ACTIONS_RUNNER_COMPARE_WORKFLOW_PARSER"))) + if ((context.Global.Variables.GetBoolean(Constants.Runner.Features.CompareWorkflowParser) ?? false) || StringUtil.ConvertToBoolean(Environment.GetEnvironmentVariable("ACTIONS_RUNNER_COMPARE_WORKFLOW_PARSER")) + || (context.Global.Variables.GetBoolean(Constants.Runner.Features.CutoverWorkflowParser) ?? false) || StringUtil.ConvertToBoolean(Environment.GetEnvironmentVariable("ACTIONS_RUNNER_CUTOVER_WORKFLOW_PARSER"))) { return (context as ExecutionContext).ToPipelineTemplateEvaluatorInternal(traceWriter); } diff --git a/src/Runner.Worker/InternalsVisibleTo.cs b/src/Runner.Worker/InternalsVisibleTo.cs new file mode 100644 index 00000000000..a825116a601 --- /dev/null +++ b/src/Runner.Worker/InternalsVisibleTo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Test")] diff --git a/src/Runner.Worker/PipelineTemplateEvaluatorWrapper.cs b/src/Runner.Worker/PipelineTemplateEvaluatorWrapper.cs index 53742469bd0..71d028f78d0 100644 --- a/src/Runner.Worker/PipelineTemplateEvaluatorWrapper.cs +++ b/src/Runner.Worker/PipelineTemplateEvaluatorWrapper.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using GitHub.Actions.WorkflowParser; using GitHub.DistributedTask.Expressions2; @@ -19,6 +19,7 @@ internal sealed class PipelineTemplateEvaluatorWrapper : IPipelineTemplateEvalua private WorkflowTemplateEvaluator _newEvaluator; private IExecutionContext _context; private Tracing _trace; + private bool _cutover; public PipelineTemplateEvaluatorWrapper( IHostContext hostContext, @@ -29,6 +30,8 @@ public PipelineTemplateEvaluatorWrapper( ArgUtil.NotNull(context, nameof(context)); _context = context; _trace = hostContext.GetTrace(nameof(PipelineTemplateEvaluatorWrapper)); + _cutover = (context.Global.Variables.GetBoolean(Constants.Runner.Features.CutoverWorkflowParser) ?? false) + || StringUtil.ConvertToBoolean(Environment.GetEnvironmentVariable("ACTIONS_RUNNER_CUTOVER_WORKFLOW_PARSER")); if (traceWriter == null) { @@ -55,7 +58,7 @@ public bool EvaluateStepContinueOnError( DictionaryContextData contextData, IList expressionFunctions) { - return EvaluateAndCompare( + return EvaluateWrapper( "EvaluateStepContinueOnError", () => _legacyEvaluator.EvaluateStepContinueOnError(token, contextData, expressionFunctions), () => _newEvaluator.EvaluateStepContinueOnError(ConvertToken(token), ConvertData(contextData), ConvertFunctions(expressionFunctions)), @@ -67,7 +70,7 @@ public string EvaluateStepDisplayName( DictionaryContextData contextData, IList expressionFunctions) { - return EvaluateAndCompare( + return EvaluateWrapper( "EvaluateStepDisplayName", () => _legacyEvaluator.EvaluateStepDisplayName(token, contextData, expressionFunctions), () => _newEvaluator.EvaluateStepName(ConvertToken(token), ConvertData(contextData), ConvertFunctions(expressionFunctions)), @@ -80,7 +83,7 @@ public Dictionary EvaluateStepEnvironment( IList expressionFunctions, StringComparer keyComparer) { - return EvaluateAndCompare( + return EvaluateWrapper( "EvaluateStepEnvironment", () => _legacyEvaluator.EvaluateStepEnvironment(token, contextData, expressionFunctions, keyComparer), () => _newEvaluator.EvaluateStepEnvironment(ConvertToken(token), ConvertData(contextData), ConvertFunctions(expressionFunctions), keyComparer), @@ -93,7 +96,7 @@ public bool EvaluateStepIf( IList expressionFunctions, IEnumerable> expressionState) { - return EvaluateAndCompare( + return EvaluateWrapper( "EvaluateStepIf", () => _legacyEvaluator.EvaluateStepIf(token, contextData, expressionFunctions, expressionState), () => _newEvaluator.EvaluateStepIf(ConvertToken(token), ConvertData(contextData), ConvertFunctions(expressionFunctions), expressionState), @@ -105,7 +108,7 @@ public Dictionary EvaluateStepInputs( DictionaryContextData contextData, IList expressionFunctions) { - return EvaluateAndCompare( + return EvaluateWrapper( "EvaluateStepInputs", () => _legacyEvaluator.EvaluateStepInputs(token, contextData, expressionFunctions), () => _newEvaluator.EvaluateStepInputs(ConvertToken(token), ConvertData(contextData), ConvertFunctions(expressionFunctions)), @@ -117,7 +120,7 @@ public int EvaluateStepTimeout( DictionaryContextData contextData, IList expressionFunctions) { - return EvaluateAndCompare( + return EvaluateWrapper( "EvaluateStepTimeout", () => _legacyEvaluator.EvaluateStepTimeout(token, contextData, expressionFunctions), () => _newEvaluator.EvaluateStepTimeout(ConvertToken(token), ConvertData(contextData), ConvertFunctions(expressionFunctions)), @@ -129,7 +132,7 @@ public GitHub.DistributedTask.Pipelines.JobContainer EvaluateJobContainer( DictionaryContextData contextData, IList expressionFunctions) { - return EvaluateAndCompare( + return EvaluateWrapper( "EvaluateJobContainer", () => _legacyEvaluator.EvaluateJobContainer(token, contextData, expressionFunctions), () => _newEvaluator.EvaluateJobContainer(ConvertToken(token), ConvertData(contextData), ConvertFunctions(expressionFunctions)), @@ -141,7 +144,7 @@ public Dictionary EvaluateJobOutput( DictionaryContextData contextData, IList expressionFunctions) { - return EvaluateAndCompare( + return EvaluateWrapper( "EvaluateJobOutput", () => _legacyEvaluator.EvaluateJobOutput(token, contextData, expressionFunctions), () => _newEvaluator.EvaluateJobOutputs(ConvertToken(token), ConvertData(contextData), ConvertFunctions(expressionFunctions)), @@ -153,7 +156,7 @@ public TemplateToken EvaluateEnvironmentUrl( DictionaryContextData contextData, IList expressionFunctions) { - return EvaluateAndCompare( + return EvaluateWrapper( "EvaluateEnvironmentUrl", () => _legacyEvaluator.EvaluateEnvironmentUrl(token, contextData, expressionFunctions), () => _newEvaluator.EvaluateJobEnvironmentUrl(ConvertToken(token), ConvertData(contextData), ConvertFunctions(expressionFunctions)), @@ -165,7 +168,7 @@ public Dictionary EvaluateJobDefaultsRun( DictionaryContextData contextData, IList expressionFunctions) { - return EvaluateAndCompare( + return EvaluateWrapper( "EvaluateJobDefaultsRun", () => _legacyEvaluator.EvaluateJobDefaultsRun(token, contextData, expressionFunctions), () => _newEvaluator.EvaluateJobDefaultsRun(ConvertToken(token), ConvertData(contextData), ConvertFunctions(expressionFunctions)), @@ -177,7 +180,7 @@ public Dictionary EvaluateJobDefaultsRun( DictionaryContextData contextData, IList expressionFunctions) { - return EvaluateAndCompare( + return EvaluateWrapper( "EvaluateJobServiceContainers", () => _legacyEvaluator.EvaluateJobServiceContainers(token, contextData, expressionFunctions), () => _newEvaluator.EvaluateJobServiceContainers(ConvertToken(token), ConvertData(contextData), ConvertFunctions(expressionFunctions)), @@ -189,7 +192,7 @@ public GitHub.DistributedTask.Pipelines.Snapshot EvaluateJobSnapshotRequest( DictionaryContextData contextData, IList expressionFunctions) { - return EvaluateAndCompare( + return EvaluateWrapper( "EvaluateJobSnapshotRequest", () => _legacyEvaluator.EvaluateJobSnapshotRequest(token, contextData, expressionFunctions), () => _newEvaluator.EvaluateSnapshot(string.Empty, ConvertToken(token), ConvertData(contextData), ConvertFunctions(expressionFunctions)), @@ -216,12 +219,25 @@ private void RecordComparisonError(string errorDetails) } } - private TLegacy EvaluateAndCompare( + private TLegacy EvaluateWrapper( string methodName, Func legacyEvaluator, Func newEvaluator, Func resultComparer) { + // Cutover: use only the new evaluator, convert result to legacy type + if (_cutover) + { + var newResult = newEvaluator(); + if (typeof(TLegacy) == typeof(TNew)) + { + return (TLegacy)(object)newResult; + } + + var json = StringUtil.ConvertToJson(newResult, Newtonsoft.Json.Formatting.None); + return StringUtil.ConvertFromJson(json); + } + // Legacy evaluator var legacyException = default(Exception); var legacyResult = default(TLegacy); diff --git a/src/Test/L0/Worker/ActionManifestParserComparisonL0.cs b/src/Test/L0/Worker/ActionManifestParserComparisonL0.cs new file mode 100644 index 00000000000..a3bd10cfde3 --- /dev/null +++ b/src/Test/L0/Worker/ActionManifestParserComparisonL0.cs @@ -0,0 +1,456 @@ +using GitHub.Actions.WorkflowParser; +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 Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.IO; +using System.Runtime.CompilerServices; +using System.Threading; +using Xunit; + +namespace GitHub.Runner.Common.Tests.Worker +{ + /// + /// Tests for parser comparison wrapper classes. + /// + public sealed class ActionManifestParserComparisonL0 + { + private CancellationTokenSource _ecTokenSource; + private Mock _ec; + private TestHostContext _hc; + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void ConvertToLegacySteps_ProducesCorrectSteps_WithExplicitPropertyMapping() + { + try + { + // Arrange - Test that ActionManifestManagerWrapper properly converts new steps to legacy format + Setup(); + + // Enable comparison feature + _ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true"); + + // Register required services + 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 manifestPath = Path.Combine(TestUtil.GetTestDataPath(), "conditional_composite_action.yml"); + + // Act - Load through the wrapper (which internally converts) + var result = wrapper.Load(_ec.Object, manifestPath); + + // Assert + Assert.NotNull(result); + Assert.Equal(ActionExecutionType.Composite, result.Execution.ExecutionType); + + var compositeExecution = result.Execution as CompositeActionExecutionData; + Assert.NotNull(compositeExecution); + Assert.NotNull(compositeExecution.Steps); + Assert.Equal(6, compositeExecution.Steps.Count); + + // Verify steps are NOT null (this was the bug - JSON round-trip produced nulls) + foreach (var step in compositeExecution.Steps) + { + Assert.NotNull(step); + Assert.NotNull(step.Reference); + Assert.IsType(step.Reference); + } + + // Verify step with condition + var successStep = compositeExecution.Steps[2]; + Assert.Equal("success-conditional", successStep.ContextName); + Assert.Equal("success()", successStep.Condition); + + // Verify step with complex condition + var lastStep = compositeExecution.Steps[5]; + Assert.Contains("inputs.exit-code == 1", lastStep.Condition); + Assert.Contains("failure()", lastStep.Condition); + } + finally + { + Teardown(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void EvaluateJobContainer_EmptyImage_BothParsersReturnNull() + { + try + { + // Arrange - Test that both parsers return null for empty container image at runtime + Setup(); + + var fileTable = new List(); + + // Create legacy evaluator + var legacyTraceWriter = new GitHub.DistributedTask.ObjectTemplating.EmptyTraceWriter(); + var schema = PipelineTemplateSchemaFactory.GetSchema(); + var legacyEvaluator = new PipelineTemplateEvaluator(legacyTraceWriter, schema, fileTable); + + // Create new evaluator + var newTraceWriter = new GitHub.Actions.WorkflowParser.ObjectTemplating.EmptyTraceWriter(); + var newEvaluator = new WorkflowTemplateEvaluator(newTraceWriter, fileTable, features: null); + + // Create a token representing an empty container image (simulates expression evaluated to empty string) + var emptyImageToken = new StringToken(null, null, null, ""); + + var contextData = new DictionaryContextData(); + var expressionFunctions = new List(); + + // Act - Call both evaluators + var legacyResult = legacyEvaluator.EvaluateJobContainer(emptyImageToken, contextData, expressionFunctions); + + // Convert token for new evaluator + var newToken = new GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens.StringToken(null, null, null, ""); + var newContextData = new GitHub.Actions.Expressions.Data.DictionaryExpressionData(); + var newExpressionFunctions = new List(); + + var newResult = newEvaluator.EvaluateJobContainer(newToken, newContextData, newExpressionFunctions); + + // Assert - Both should return null for empty image (no container) + Assert.Null(legacyResult); + Assert.Null(newResult); + } + finally + { + Teardown(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void FromJsonEmptyString_BothParsersFail_WithDifferentMessages() + { + // This test verifies that both parsers fail with different error messages when parsing fromJSON('') + // The comparison layer should treat these as semantically equivalent (both are JSON parse errors) + try + { + Setup(); + + var fileTable = new List(); + + // Create legacy evaluator + var legacyTraceWriter = new GitHub.DistributedTask.ObjectTemplating.EmptyTraceWriter(); + var schema = PipelineTemplateSchemaFactory.GetSchema(); + var legacyEvaluator = new PipelineTemplateEvaluator(legacyTraceWriter, schema, fileTable); + + // Create new evaluator + var newTraceWriter = new GitHub.Actions.WorkflowParser.ObjectTemplating.EmptyTraceWriter(); + var newEvaluator = new WorkflowTemplateEvaluator(newTraceWriter, fileTable, features: null); + + // Create expression token for fromJSON('') + var legacyToken = new BasicExpressionToken(null, null, null, "fromJson('')"); + var newToken = new GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens.BasicExpressionToken(null, null, null, "fromJson('')"); + + var contextData = new DictionaryContextData(); + var newContextData = new GitHub.Actions.Expressions.Data.DictionaryExpressionData(); + var expressionFunctions = new List(); + var newExpressionFunctions = new List(); + + // Act - Both should throw + Exception legacyException = null; + Exception newException = null; + + try + { + legacyEvaluator.EvaluateStepDisplayName(legacyToken, contextData, expressionFunctions); + } + catch (Exception ex) + { + legacyException = ex; + } + + try + { + newEvaluator.EvaluateStepName(newToken, newContextData, newExpressionFunctions); + } + catch (Exception ex) + { + newException = ex; + } + + // Assert - Both threw exceptions + Assert.NotNull(legacyException); + Assert.NotNull(newException); + + // Verify the error messages are different (which is why we need semantic comparison) + Assert.NotEqual(legacyException.Message, newException.Message); + + // Verify both are JSON parse errors (contain JSON-related error indicators) + var legacyFullMsg = GetFullExceptionMessage(legacyException); + var newFullMsg = GetFullExceptionMessage(newException); + + // At least one should contain indicators of JSON parsing failure + var legacyIsJsonError = legacyFullMsg.Contains("JToken") || + legacyFullMsg.Contains("JsonReader") || + legacyFullMsg.Contains("fromJson"); + var newIsJsonError = newFullMsg.Contains("JToken") || + newFullMsg.Contains("JsonReader") || + newFullMsg.Contains("fromJson"); + + Assert.True(legacyIsJsonError, $"Legacy exception should be JSON error: {legacyFullMsg}"); + Assert.True(newIsJsonError, $"New exception should be JSON error: {newFullMsg}"); + } + finally + { + Teardown(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void EvaluateWrapper_SkipsMismatchRecording_WhenCancellationOccursDuringEvaluation() + { + 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); + + // Create a simple token for evaluation + var token = new StringToken(null, null, null, "test-value"); + var contextData = new DictionaryContextData(); + var expressionFunctions = new List(); + + // 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); + + // Now simulate a scenario where cancellation occurs during evaluation + // Cancel the token before next evaluation + _ecTokenSource.Cancel(); + + // 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); + + // 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); + } + finally + { + Teardown(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void EvaluateWrapper_DoesNotRecordMismatch_WhenResultsMatch() + { + 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); + + // Create a simple token for evaluation + var token = new StringToken(null, null, null, "test-value"); + var contextData = new DictionaryContextData(); + var expressionFunctions = new List(); + + // 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); + + // Since both parsers return the same result, no mismatch should be recorded + Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch); + } + finally + { + Teardown(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void CutoverFlag_UsesNewEvaluator_ForPipelineTemplateEvaluator() + { + try + { + // Arrange - Test that cutover flag causes the wrapper to use only the new evaluator + Setup(); + + // Enable cutover feature (not comparison) + _ec.Object.Global.Variables.Set(Constants.Runner.Features.CutoverWorkflowParser, "true"); + + // Create the wrapper + var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object); + + // Create a simple token for evaluation + var token = new StringToken(null, null, null, "test-value"); + var contextData = new DictionaryContextData(); + var expressionFunctions = new List(); + + // Act - Evaluate in cutover mode + var result = wrapper.EvaluateStepDisplayName(token, contextData, expressionFunctions); + + // Assert - Should get the correct result from the new evaluator + Assert.Equal("test-value", result); + + // No mismatch should be recorded (comparison is skipped entirely in cutover mode) + Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch); + } + finally + { + Teardown(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void CutoverFlag_UsesNewManager_ForActionManifestLoad() + { + try + { + // Arrange - Test that cutover flag causes the manifest wrapper to use only the new manager + Setup(); + + // Enable cutover feature (not comparison) + _ec.Object.Global.Variables.Set(Constants.Runner.Features.CutoverWorkflowParser, "true"); + + // Register required services + 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 manifestPath = Path.Combine(TestUtil.GetTestDataPath(), "conditional_composite_action.yml"); + + // Act - Load through the wrapper in cutover mode + var result = wrapper.Load(_ec.Object, manifestPath); + + // Assert - Should get the correct result from the new manager + Assert.NotNull(result); + Assert.Equal(ActionExecutionType.Composite, result.Execution.ExecutionType); + + // No mismatch should be recorded (comparison is skipped in cutover mode) + Assert.False(_ec.Object.Global.HasActionManifestMismatch); + } + finally + { + Teardown(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void CutoverFlag_TakesPrecedence_OverCompareFlag() + { + try + { + // Arrange - Test that cutover flag takes precedence over compare flag + Setup(); + + // Enable both flags + _ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true"); + _ec.Object.Global.Variables.Set(Constants.Runner.Features.CutoverWorkflowParser, "true"); + + // Create the wrapper + var wrapper = new PipelineTemplateEvaluatorWrapper(_hc, _ec.Object); + + var token = new StringToken(null, null, null, "test-value"); + var contextData = new DictionaryContextData(); + var expressionFunctions = new List(); + + // Act - Evaluate (cutover should take precedence, skipping comparison entirely) + var result = wrapper.EvaluateStepDisplayName(token, contextData, expressionFunctions); + + // Assert - Should get correct result, no comparison mismatch recorded + Assert.Equal("test-value", result); + Assert.False(_ec.Object.Global.HasTemplateEvaluatorMismatch); + } + finally + { + Teardown(); + } + } + + private string GetFullExceptionMessage(Exception ex) + { + var messages = new List(); + var current = ex; + while (current != null) + { + messages.Add(current.Message); + current = current.InnerException; + } + return string.Join(" -> ", messages); + } + + 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(); + } + } +}