From 2a98a8c9550c941b719bb7208b7f575e073c2799 Mon Sep 17 00:00:00 2001 From: Francesco Renzi Date: Thu, 12 Mar 2026 09:56:28 +0000 Subject: [PATCH] Add L0 tests for DAP expression evaluation Provider tests (DapVariableProviderL0): - Simple expression evaluation (github.repository) - ${{ }} wrapper stripping - Secret masking in evaluation results - Graceful error for invalid expressions - No-context returns descriptive message - Empty expression returns empty string - InferResultType classifies null/bool/number/object/string Session integration tests (DapDebugSessionL0): - evaluate request returns result when paused with context - evaluate request returns graceful error when no step active - evaluate request handles ${{ }} wrapper syntax Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Test/L0/Worker/DapDebugSessionL0.cs | 165 ++++++++++++++++++++ src/Test/L0/Worker/DapVariableProviderL0.cs | 165 ++++++++++++++++++++ 2 files changed, 330 insertions(+) diff --git a/src/Test/L0/Worker/DapDebugSessionL0.cs b/src/Test/L0/Worker/DapDebugSessionL0.cs index 0962970eb..a26ee8716 100644 --- a/src/Test/L0/Worker/DapDebugSessionL0.cs +++ b/src/Test/L0/Worker/DapDebugSessionL0.cs @@ -803,5 +803,170 @@ namespace GitHub.Runner.Common.Tests.Worker } #endregion + + #region Evaluate request integration tests + + private Mock CreateMockStepWithEvaluatableContext( + TestHostContext hc, + string displayName, + DictionaryContextData expressionValues, + TaskResult? result = null) + { + var mockEc = new Mock(); + mockEc.SetupAllProperties(); + mockEc.Object.Result = result; + mockEc.Setup(x => x.ExpressionValues).Returns(expressionValues); + mockEc.Setup(x => x.ExpressionFunctions) + .Returns(new List()); + mockEc.Setup(x => x.Global).Returns(new GlobalContext + { + FileTable = new List(), + Variables = new Variables(hc, new Dictionary()), + }); + mockEc.Setup(x => x.Write(It.IsAny(), It.IsAny())); + + var mockStep = new Mock(); + mockStep.Setup(x => x.DisplayName).Returns(displayName); + mockStep.Setup(x => x.ExecutionContext).Returns(mockEc.Object); + + return mockStep; + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task EvaluateRequestReturnsResult() + { + using (var hc = CreateTestContext()) + { + await InitializeSessionAsync(); + _session.HandleClientConnected(); + + var exprValues = new DictionaryContextData(); + exprValues["github"] = new DictionaryContextData + { + { "repository", new StringContextData("owner/repo") } + }; + + var step = CreateMockStepWithEvaluatableContext(hc, "Run tests", exprValues); + var jobContext = CreateMockJobContext(); + + var stepTask = _session.OnStepStartingAsync(step.Object, jobContext.Object, isFirstStep: true, CancellationToken.None); + await Task.Delay(100); + + var evaluateJson = JsonConvert.SerializeObject(new Request + { + Seq = 20, + Type = "request", + Command = "evaluate", + Arguments = Newtonsoft.Json.Linq.JObject.FromObject(new EvaluateArguments + { + Expression = "github.repository", + FrameId = 1, + Context = "watch" + }) + }); + _sentResponses.Clear(); + await _session.HandleMessageAsync(evaluateJson, CancellationToken.None); + + Assert.Single(_sentResponses); + Assert.True(_sentResponses[0].Success); + + // Resume to unblock + var continueJson = JsonConvert.SerializeObject(new Request + { + Seq = 21, + Type = "request", + Command = "continue" + }); + await _session.HandleMessageAsync(continueJson, CancellationToken.None); + await Task.WhenAny(stepTask, Task.Delay(5000)); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task EvaluateRequestReturnsGracefulErrorWhenNoContext() + { + using (CreateTestContext()) + { + await InitializeSessionAsync(); + + // No step is active — evaluate should still succeed with + // a descriptive "no context" message, not an error response. + var evaluateJson = JsonConvert.SerializeObject(new Request + { + Seq = 10, + Type = "request", + Command = "evaluate", + Arguments = Newtonsoft.Json.Linq.JObject.FromObject(new EvaluateArguments + { + Expression = "github.repository", + FrameId = 1, + Context = "hover" + }) + }); + _sentResponses.Clear(); + await _session.HandleMessageAsync(evaluateJson, CancellationToken.None); + + Assert.Single(_sentResponses); + Assert.True(_sentResponses[0].Success); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async Task EvaluateRequestWithWrapperSyntax() + { + using (var hc = CreateTestContext()) + { + await InitializeSessionAsync(); + _session.HandleClientConnected(); + + var exprValues = new DictionaryContextData(); + exprValues["github"] = new DictionaryContextData + { + { "event_name", new StringContextData("push") } + }; + + var step = CreateMockStepWithEvaluatableContext(hc, "Run tests", exprValues); + var jobContext = CreateMockJobContext(); + + var stepTask = _session.OnStepStartingAsync(step.Object, jobContext.Object, isFirstStep: true, CancellationToken.None); + await Task.Delay(100); + + var evaluateJson = JsonConvert.SerializeObject(new Request + { + Seq = 20, + Type = "request", + Command = "evaluate", + Arguments = Newtonsoft.Json.Linq.JObject.FromObject(new EvaluateArguments + { + Expression = "${{ github.event_name }}", + FrameId = 1, + Context = "watch" + }) + }); + _sentResponses.Clear(); + await _session.HandleMessageAsync(evaluateJson, CancellationToken.None); + + Assert.Single(_sentResponses); + Assert.True(_sentResponses[0].Success); + + // Resume to unblock + var continueJson = JsonConvert.SerializeObject(new Request + { + Seq = 21, + Type = "request", + Command = "continue" + }); + await _session.HandleMessageAsync(continueJson, CancellationToken.None); + await Task.WhenAny(stepTask, Task.Delay(5000)); + } + } + + #endregion } } diff --git a/src/Test/L0/Worker/DapVariableProviderL0.cs b/src/Test/L0/Worker/DapVariableProviderL0.cs index fd63dccfc..3dfb83f57 100644 --- a/src/Test/L0/Worker/DapVariableProviderL0.cs +++ b/src/Test/L0/Worker/DapVariableProviderL0.cs @@ -2,8 +2,10 @@ using System.Collections.Generic; using System.Runtime.CompilerServices; using GitHub.DistributedTask.Pipelines.ContextData; +using GitHub.DistributedTask.WebApi; using GitHub.Runner.Common; using GitHub.Runner.Common.Tests; +using GitHub.Runner.Worker; using GitHub.Runner.Worker.Dap; using Xunit; @@ -500,5 +502,168 @@ namespace GitHub.Runner.Common.Tests.Worker } #endregion + + #region EvaluateExpression + + /// + /// Creates a mock execution context with Global set up so that + /// ToPipelineTemplateEvaluator() works for real expression evaluation. + /// + private Moq.Mock CreateEvaluatableContext( + TestHostContext hc, + DictionaryContextData expressionValues) + { + var mock = new Moq.Mock(); + mock.Setup(x => x.ExpressionValues).Returns(expressionValues); + mock.Setup(x => x.ExpressionFunctions) + .Returns(new List()); + mock.Setup(x => x.Global).Returns(new GlobalContext + { + FileTable = new List(), + Variables = new Variables(hc, new Dictionary()), + }); + // ToPipelineTemplateEvaluator uses ToTemplateTraceWriter which calls + // context.Write — provide a no-op so it doesn't NRE. + mock.Setup(x => x.Write(Moq.It.IsAny(), Moq.It.IsAny())); + return mock; + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void EvaluateExpression_ReturnsValueForSimpleExpression() + { + using (var hc = CreateTestContext()) + { + var exprValues = new DictionaryContextData(); + exprValues["github"] = new DictionaryContextData + { + { "repository", new StringContextData("owner/repo") } + }; + + var ctx = CreateEvaluatableContext(hc, exprValues); + var result = _provider.EvaluateExpression("github.repository", ctx.Object); + + Assert.Equal("owner/repo", result.Result); + Assert.Equal("string", result.Type); + Assert.Equal(0, result.VariablesReference); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void EvaluateExpression_StripsWrapperSyntax() + { + using (var hc = CreateTestContext()) + { + var exprValues = new DictionaryContextData(); + exprValues["github"] = new DictionaryContextData + { + { "event_name", new StringContextData("push") } + }; + + var ctx = CreateEvaluatableContext(hc, exprValues); + var result = _provider.EvaluateExpression("${{ github.event_name }}", ctx.Object); + + Assert.Equal("push", result.Result); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void EvaluateExpression_MasksSecretInResult() + { + using (var hc = CreateTestContext()) + { + hc.SecretMasker.AddValue("super-secret"); + + var exprValues = new DictionaryContextData(); + exprValues["env"] = new DictionaryContextData + { + { "TOKEN", new StringContextData("super-secret") } + }; + + var ctx = CreateEvaluatableContext(hc, exprValues); + var result = _provider.EvaluateExpression("env.TOKEN", ctx.Object); + + Assert.DoesNotContain("super-secret", result.Result); + Assert.Contains("***", result.Result); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void EvaluateExpression_ReturnsErrorForInvalidExpression() + { + using (var hc = CreateTestContext()) + { + var exprValues = new DictionaryContextData(); + exprValues["github"] = new DictionaryContextData(); + + var ctx = CreateEvaluatableContext(hc, exprValues); + // An invalid expression syntax should not throw — it should + // return an error result. + var result = _provider.EvaluateExpression("!!!invalid[[", ctx.Object); + + Assert.Contains("error", result.Result, StringComparison.OrdinalIgnoreCase); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void EvaluateExpression_ReturnsMessageWhenNoContext() + { + using (CreateTestContext()) + { + var result = _provider.EvaluateExpression("github.repository", null); + + Assert.Contains("no execution context", result.Result, StringComparison.OrdinalIgnoreCase); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void EvaluateExpression_ReturnsEmptyForEmptyExpression() + { + using (var hc = CreateTestContext()) + { + var exprValues = new DictionaryContextData(); + var ctx = CreateEvaluatableContext(hc, exprValues); + var result = _provider.EvaluateExpression("", ctx.Object); + + Assert.Equal(string.Empty, result.Result); + } + } + + #endregion + + #region InferResultType + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void InferResultType_ClassifiesCorrectly() + { + using (CreateTestContext()) + { + Assert.Equal("null", DapVariableProvider.InferResultType(null)); + Assert.Equal("null", DapVariableProvider.InferResultType("null")); + Assert.Equal("boolean", DapVariableProvider.InferResultType("true")); + Assert.Equal("boolean", DapVariableProvider.InferResultType("false")); + Assert.Equal("number", DapVariableProvider.InferResultType("42")); + Assert.Equal("number", DapVariableProvider.InferResultType("3.14")); + Assert.Equal("object", DapVariableProvider.InferResultType("{\"key\":\"val\"}")); + Assert.Equal("object", DapVariableProvider.InferResultType("[1,2,3]")); + Assert.Equal("string", DapVariableProvider.InferResultType("hello world")); + Assert.Equal("string", DapVariableProvider.InferResultType("owner/repo")); + } + } + + #endregion } }