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>
This commit is contained in:
Francesco Renzi
2026-03-12 09:56:28 +00:00
committed by GitHub
parent f31e1c7c43
commit 2a98a8c955
2 changed files with 330 additions and 0 deletions

View File

@@ -803,5 +803,170 @@ namespace GitHub.Runner.Common.Tests.Worker
}
#endregion
#region Evaluate request integration tests
private Mock<IStep> CreateMockStepWithEvaluatableContext(
TestHostContext hc,
string displayName,
DictionaryContextData expressionValues,
TaskResult? result = null)
{
var mockEc = new Mock<IExecutionContext>();
mockEc.SetupAllProperties();
mockEc.Object.Result = result;
mockEc.Setup(x => x.ExpressionValues).Returns(expressionValues);
mockEc.Setup(x => x.ExpressionFunctions)
.Returns(new List<GitHub.DistributedTask.Expressions2.IFunctionInfo>());
mockEc.Setup(x => x.Global).Returns(new GlobalContext
{
FileTable = new List<string>(),
Variables = new Variables(hc, new Dictionary<string, VariableValue>()),
});
mockEc.Setup(x => x.Write(It.IsAny<string>(), It.IsAny<string>()));
var mockStep = new Mock<IStep>();
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
}
}

View File

@@ -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
/// <summary>
/// Creates a mock execution context with Global set up so that
/// ToPipelineTemplateEvaluator() works for real expression evaluation.
/// </summary>
private Moq.Mock<IExecutionContext> CreateEvaluatableContext(
TestHostContext hc,
DictionaryContextData expressionValues)
{
var mock = new Moq.Mock<IExecutionContext>();
mock.Setup(x => x.ExpressionValues).Returns(expressionValues);
mock.Setup(x => x.ExpressionFunctions)
.Returns(new List<GitHub.DistributedTask.Expressions2.IFunctionInfo>());
mock.Setup(x => x.Global).Returns(new GlobalContext
{
FileTable = new List<string>(),
Variables = new Variables(hc, new Dictionary<string, VariableValue>()),
});
// ToPipelineTemplateEvaluator uses ToTemplateTraceWriter which calls
// context.Write — provide a no-op so it doesn't NRE.
mock.Setup(x => x.Write(Moq.It.IsAny<string>(), Moq.It.IsAny<string>()));
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
}
}