Compare commits

..

5 Commits

Author SHA1 Message Date
dependabot[bot]
1f73627937 Bump System.Security.Cryptography.ProtectedData from 8.0.0 to 10.0.3
---
updated-dependencies:
- dependency-name: System.Security.Cryptography.ProtectedData
  dependency-version: 10.0.3
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-12 04:54:32 +00:00
eric sciple
15cb558d8f Fix parser comparison mismatches (#4220) 2026-02-11 09:44:01 -06:00
eric sciple
d5a8a936c1 Add telemetry tracking for deprecated set-output and save-state commands (#4221) 2026-02-10 12:28:42 -06:00
Tingluo Huang
cdb77c6804 Support return job result as exitcode in hosted runner. (#4233) 2026-02-10 09:31:10 -05:00
Nikola Jokic
a4a19b152e Bump hook to 0.8.1 (#4222) 2026-02-10 01:07:20 +00:00
20 changed files with 5089 additions and 31418 deletions

View File

@@ -21,7 +21,7 @@ RUN curl -f -L -o runner-container-hooks.zip https://github.com/actions/runner-c
&& unzip ./runner-container-hooks.zip -d ./k8s \
&& rm runner-container-hooks.zip
RUN curl -f -L -o runner-container-hooks.zip https://github.com/actions/runner-container-hooks/releases/download/v0.8.0/actions-runner-hooks-k8s-0.8.0.zip \
RUN curl -f -L -o runner-container-hooks.zip https://github.com/actions/runner-container-hooks/releases/download/v0.8.1/actions-runner-hooks-k8s-0.8.1.zip \
&& unzip ./runner-container-hooks.zip -d ./k8s-novolume \
&& rm runner-container-hooks.zip

View File

@@ -9,7 +9,7 @@
"version": "1.0.0",
"license": "MIT",
"dependencies": {
"@actions/glob": "^0.6.1"
"@actions/glob": "^0.4.0"
},
"devDependencies": {
"@stylistic/eslint-plugin": "^3.1.0",
@@ -36,45 +36,31 @@
}
},
"node_modules/@actions/core": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@actions/core/-/core-3.0.0.tgz",
"integrity": "sha512-zYt6cz+ivnTmiT/ksRVriMBOiuoUpDCJJlZ5KPl2/FRdvwU3f7MPh9qftvbkXJThragzUZieit2nyHUyw53Seg==",
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@actions/core/-/core-1.10.0.tgz",
"integrity": "sha512-2aZDDa3zrrZbP5ZYg159sNoLRb61nQ7awl5pSvIq5Qpj81vwDzdMRKzkWJGJuwVvWpvZKx7vspJALyvaaIQyug==",
"dependencies": {
"@actions/exec": "^3.0.0",
"@actions/http-client": "^4.0.0"
}
},
"node_modules/@actions/exec": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@actions/exec/-/exec-3.0.0.tgz",
"integrity": "sha512-6xH/puSoNBXb72VPlZVm7vQ+svQpFyA96qdDBvhB8eNZOE8LtPf9L4oAsfzK/crCL8YZ+19fKYVnM63Sl+Xzlw==",
"dependencies": {
"@actions/io": "^3.0.2"
"@actions/http-client": "^2.0.1",
"uuid": "^8.3.2"
}
},
"node_modules/@actions/glob": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/@actions/glob/-/glob-0.6.1.tgz",
"integrity": "sha512-K4+2Ac5ILcf2ySdJCha+Pop9NcKjxqCL4xL4zI50dgB2PbXgC0+AcP011xfH4Of6b4QEJJg8dyZYv7zl4byTsw==",
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@actions/glob/-/glob-0.4.0.tgz",
"integrity": "sha512-+eKIGFhsFa4EBwaf/GMyzCdWrXWymGXfFmZU3FHQvYS8mPcHtTtZONbkcqqUMzw9mJ/pImEBFET1JNifhqGsAQ==",
"dependencies": {
"@actions/core": "^3.0.0",
"@actions/core": "^1.9.1",
"minimatch": "^3.0.4"
}
},
"node_modules/@actions/http-client": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-4.0.0.tgz",
"integrity": "sha512-QuwPsgVMsD6qaPD57GLZi9sqzAZCtiJT8kVBCDpLtxhL5MydQ4gS+DrejtZZPdIYyB1e95uCK9Luyds7ybHI3g==",
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-2.1.1.tgz",
"integrity": "sha512-qhrkRMB40bbbLo7gF+0vu+X+UawOvQQqNAA/5Unx774RS8poaOhThDOG6BGmxvAnxhQnDp2BG/ZUm65xZILTpw==",
"dependencies": {
"tunnel": "^0.0.6",
"undici": "^6.23.0"
"tunnel": "^0.0.6"
}
},
"node_modules/@actions/io": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@actions/io/-/io-3.0.2.tgz",
"integrity": "sha512-nRBchcMM+QK1pdjO7/idu86rbJI5YHUKCvKs0KxnSYbVe3F51UfGxuZX4Qy/fWlp6l7gWFwIkrOzN+oUK03kfw=="
},
"node_modules/@babel/runtime": {
"version": "7.26.10",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.10.tgz",
@@ -4480,14 +4466,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/undici": {
"version": "6.23.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.23.0.tgz",
"integrity": "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==",
"engines": {
"node": ">=18.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
@@ -4543,6 +4521,14 @@
"punycode": "^2.1.0"
}
},
"node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -4693,45 +4679,31 @@
"dev": true
},
"@actions/core": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@actions/core/-/core-3.0.0.tgz",
"integrity": "sha512-zYt6cz+ivnTmiT/ksRVriMBOiuoUpDCJJlZ5KPl2/FRdvwU3f7MPh9qftvbkXJThragzUZieit2nyHUyw53Seg==",
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@actions/core/-/core-1.10.0.tgz",
"integrity": "sha512-2aZDDa3zrrZbP5ZYg159sNoLRb61nQ7awl5pSvIq5Qpj81vwDzdMRKzkWJGJuwVvWpvZKx7vspJALyvaaIQyug==",
"requires": {
"@actions/exec": "^3.0.0",
"@actions/http-client": "^4.0.0"
}
},
"@actions/exec": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@actions/exec/-/exec-3.0.0.tgz",
"integrity": "sha512-6xH/puSoNBXb72VPlZVm7vQ+svQpFyA96qdDBvhB8eNZOE8LtPf9L4oAsfzK/crCL8YZ+19fKYVnM63Sl+Xzlw==",
"requires": {
"@actions/io": "^3.0.2"
"@actions/http-client": "^2.0.1",
"uuid": "^8.3.2"
}
},
"@actions/glob": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/@actions/glob/-/glob-0.6.1.tgz",
"integrity": "sha512-K4+2Ac5ILcf2ySdJCha+Pop9NcKjxqCL4xL4zI50dgB2PbXgC0+AcP011xfH4Of6b4QEJJg8dyZYv7zl4byTsw==",
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@actions/glob/-/glob-0.4.0.tgz",
"integrity": "sha512-+eKIGFhsFa4EBwaf/GMyzCdWrXWymGXfFmZU3FHQvYS8mPcHtTtZONbkcqqUMzw9mJ/pImEBFET1JNifhqGsAQ==",
"requires": {
"@actions/core": "^3.0.0",
"@actions/core": "^1.9.1",
"minimatch": "^3.0.4"
}
},
"@actions/http-client": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-4.0.0.tgz",
"integrity": "sha512-QuwPsgVMsD6qaPD57GLZi9sqzAZCtiJT8kVBCDpLtxhL5MydQ4gS+DrejtZZPdIYyB1e95uCK9Luyds7ybHI3g==",
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-2.1.1.tgz",
"integrity": "sha512-qhrkRMB40bbbLo7gF+0vu+X+UawOvQQqNAA/5Unx774RS8poaOhThDOG6BGmxvAnxhQnDp2BG/ZUm65xZILTpw==",
"requires": {
"tunnel": "^0.0.6",
"undici": "^6.23.0"
"tunnel": "^0.0.6"
}
},
"@actions/io": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@actions/io/-/io-3.0.2.tgz",
"integrity": "sha512-nRBchcMM+QK1pdjO7/idu86rbJI5YHUKCvKs0KxnSYbVe3F51UfGxuZX4Qy/fWlp6l7gWFwIkrOzN+oUK03kfw=="
},
"@babel/runtime": {
"version": "7.26.10",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.10.tgz",
@@ -7688,11 +7660,6 @@
"which-boxed-primitive": "^1.0.2"
}
},
"undici": {
"version": "6.23.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.23.0.tgz",
"integrity": "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g=="
},
"undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
@@ -7724,6 +7691,11 @@
"punycode": "^2.1.0"
}
},
"uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="
},
"which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

View File

@@ -2,14 +2,13 @@
"name": "hashFiles",
"version": "1.0.0",
"description": "GitHub Actions HashFiles() expression function",
"type": "module",
"main": "lib/hashFiles.js",
"scripts": {
"build": "tsc",
"format": "prettier --write **/*.ts",
"format-check": "prettier --check **/*.ts",
"lint": "eslint src/**/*.ts",
"pack": "ncc build -o ../../layoutbin/hashFiles --target es2020",
"pack": "ncc build -o ../../layoutbin/hashFiles",
"all": "npm run format && npm run lint && npm run build && npm run pack",
"prepare": "cd ../../../../ && husky"
},
@@ -33,7 +32,7 @@
"author": "GitHub Actions",
"license": "MIT",
"dependencies": {
"@actions/glob": "^0.6.1"
"@actions/glob": "^0.4.0"
},
"devDependencies": {
"@stylistic/eslint-plugin": "^3.1.0",

View File

@@ -1,8 +1,7 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ES2020",
"moduleResolution": "node",
"target": "es6", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */
"module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */
"outDir": "./lib", /* Redirect output structure to the directory. */
"rootDir": "./src", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
"strict": true, /* Enable all strict type-checking options. */

File diff suppressed because one or more lines are too long

View File

@@ -1,3 +0,0 @@
{
"type": "module"
}

View File

@@ -24,7 +24,7 @@ namespace GitHub.Runner.Listener
public interface IJobDispatcher : IRunnerService
{
bool Busy { get; }
TaskCompletionSource<bool> RunOnceJobCompleted { get; }
TaskCompletionSource<TaskResult> RunOnceJobCompleted { get; }
void Run(Pipelines.AgentJobRequestMessage message, bool runOnce = false);
bool Cancel(JobCancelMessage message);
Task WaitAsync(CancellationToken token);
@@ -56,7 +56,7 @@ namespace GitHub.Runner.Listener
// timeout limit can be overwritten by environment GITHUB_ACTIONS_RUNNER_CHANNEL_TIMEOUT
private TimeSpan _channelTimeout;
private TaskCompletionSource<bool> _runOnceJobCompleted = new();
private TaskCompletionSource<TaskResult> _runOnceJobCompleted = new();
public event EventHandler<JobStatusEventArgs> JobStatus;
@@ -82,7 +82,7 @@ namespace GitHub.Runner.Listener
Trace.Info($"Set runner/worker IPC timeout to {_channelTimeout.TotalSeconds} seconds.");
}
public TaskCompletionSource<bool> RunOnceJobCompleted => _runOnceJobCompleted;
public TaskCompletionSource<TaskResult> RunOnceJobCompleted => _runOnceJobCompleted;
public bool Busy { get; private set; }
@@ -340,18 +340,19 @@ namespace GitHub.Runner.Listener
private async Task RunOnceAsync(Pipelines.AgentJobRequestMessage message, string orchestrationId, WorkerDispatcher previousJobDispatch, CancellationToken jobRequestCancellationToken, CancellationToken workerCancelTimeoutKillToken)
{
var jobResult = TaskResult.Succeeded;
try
{
await RunAsync(message, orchestrationId, previousJobDispatch, jobRequestCancellationToken, workerCancelTimeoutKillToken);
jobResult = await RunAsync(message, orchestrationId, previousJobDispatch, jobRequestCancellationToken, workerCancelTimeoutKillToken);
}
finally
{
Trace.Info("Fire signal for one time used runner.");
_runOnceJobCompleted.TrySetResult(true);
_runOnceJobCompleted.TrySetResult(jobResult);
}
}
private async Task RunAsync(Pipelines.AgentJobRequestMessage message, string orchestrationId, WorkerDispatcher previousJobDispatch, CancellationToken jobRequestCancellationToken, CancellationToken workerCancelTimeoutKillToken)
private async Task<TaskResult> RunAsync(Pipelines.AgentJobRequestMessage message, string orchestrationId, WorkerDispatcher previousJobDispatch, CancellationToken jobRequestCancellationToken, CancellationToken workerCancelTimeoutKillToken)
{
Busy = true;
try
@@ -399,7 +400,7 @@ namespace GitHub.Runner.Listener
{
// renew job request task complete means we run out of retry for the first job request renew.
Trace.Info($"Unable to renew job request for job {message.JobId} for the first time, stop dispatching job to worker.");
return;
return TaskResult.Abandoned;
}
if (jobRequestCancellationToken.IsCancellationRequested)
@@ -412,7 +413,7 @@ namespace GitHub.Runner.Listener
// complete job request with result Cancelled
await CompleteJobRequestAsync(_poolId, message, systemConnection, lockToken, TaskResult.Canceled);
return;
return TaskResult.Canceled;
}
HostContext.WritePerfCounter($"JobRequestRenewed_{requestId.ToString()}");
@@ -523,7 +524,7 @@ namespace GitHub.Runner.Listener
await renewJobRequest;
// not finish the job request since the job haven't run on worker at all, we will not going to set a result to server.
return;
return TaskResult.Failed;
}
// we get first jobrequest renew succeed and start the worker process with the job message.
@@ -604,7 +605,7 @@ namespace GitHub.Runner.Listener
Trace.Error(detailInfo);
}
return;
return TaskResultUtil.TranslateFromReturnCode(returnCode);
}
else if (completedTask == renewJobRequest)
{
@@ -706,6 +707,8 @@ namespace GitHub.Runner.Listener
// complete job request
await CompleteJobRequestAsync(_poolId, message, systemConnection, lockToken, resultOnAbandonOrCancel);
return resultOnAbandonOrCancel;
}
finally
{

View File

@@ -324,8 +324,11 @@ namespace GitHub.Runner.Listener
HostContext.EnableAuthMigration("EnableAuthMigrationByDefault");
}
// hosted runner only run one job and would like to know the result of the job for telemetry and alerting on failure spike.
var returnJobResultForHosted = StringUtil.ConvertToBoolean(Environment.GetEnvironmentVariable("ACTIONS_RUNNER_RETURN_JOB_RESULT_FOR_HOSTED"));
// Run the runner interactively or as service
return await ExecuteRunnerAsync(settings, command.RunOnce || settings.Ephemeral);
return await ExecuteRunnerAsync(settings, command.RunOnce || settings.Ephemeral || returnJobResultForHosted, returnJobResultForHosted);
}
else
{
@@ -401,7 +404,7 @@ namespace GitHub.Runner.Listener
}
//create worker manager, create message listener and start listening to the queue
private async Task<int> RunAsync(RunnerSettings settings, bool runOnce = false)
private async Task<int> RunAsync(RunnerSettings settings, bool runOnce = false, bool returnRunOnceJobResult = false)
{
try
{
@@ -580,6 +583,21 @@ namespace GitHub.Runner.Listener
Trace.Info($"Ignore any exception after cancel message loop. {ex}");
}
if (returnRunOnceJobResult)
{
try
{
var jobResult = await jobDispatcher.RunOnceJobCompleted.Task;
return TaskResultUtil.TranslateToReturnCode(jobResult);
}
catch (Exception ex)
{
Trace.Error("run once job finished with error.");
Trace.Error(ex);
return Constants.Runner.ReturnCode.TerminatedError;
}
}
return Constants.Runner.ReturnCode.Success;
}
}
@@ -866,14 +884,14 @@ namespace GitHub.Runner.Listener
return Constants.Runner.ReturnCode.Success;
}
private async Task<int> ExecuteRunnerAsync(RunnerSettings settings, bool runOnce)
private async Task<int> ExecuteRunnerAsync(RunnerSettings settings, bool runOnce, bool returnRunOnceJobResult)
{
int returnCode = Constants.Runner.ReturnCode.Success;
bool restart = false;
do
{
restart = false;
returnCode = await RunAsync(settings, runOnce);
returnCode = await RunAsync(settings, runOnce, returnRunOnceJobResult);
if (returnCode == Constants.Runner.ReturnCode.RunnerConfigurationRefreshed)
{

View File

@@ -318,6 +318,17 @@ namespace GitHub.Runner.Worker
context.AddIssue(issue, ExecutionContextLogOptions.Default);
}
if (!context.Global.HasDeprecatedSetOutput)
{
context.Global.HasDeprecatedSetOutput = true;
var telemetry = new JobTelemetry
{
Type = JobTelemetryType.ActionCommand,
Message = "DeprecatedCommand: set-output"
};
context.Global.JobTelemetry.Add(telemetry);
}
if (!command.Properties.TryGetValue(SetOutputCommandProperties.Name, out string outputName) || string.IsNullOrEmpty(outputName))
{
throw new Exception("Required field 'name' is missing in ##[set-output] command.");
@@ -353,6 +364,17 @@ namespace GitHub.Runner.Worker
context.AddIssue(issue, ExecutionContextLogOptions.Default);
}
if (!context.Global.HasDeprecatedSaveState)
{
context.Global.HasDeprecatedSaveState = true;
var telemetry = new JobTelemetry
{
Type = JobTelemetryType.ActionCommand,
Message = "DeprecatedCommand: save-state"
};
context.Global.JobTelemetry.Add(telemetry);
}
if (!command.Properties.TryGetValue(SaveStateCommandProperties.Name, out string stateName) || string.IsNullOrEmpty(stateName))
{
throw new Exception("Required field 'name' is missing in ##[save-state] command.");

View File

@@ -84,7 +84,8 @@ namespace GitHub.Runner.Worker
"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");
});
@@ -165,9 +166,150 @@ namespace GitHub.Runner.Worker
return null;
}
// Serialize new steps and deserialize to old steps
var json = StringUtil.ConvertToJson(newSteps, Newtonsoft.Json.Formatting.None);
return StringUtil.ConvertFromJson<List<GitHub.DistributedTask.Pipelines.ActionStep>>(json);
var result = new List<GitHub.DistributedTask.Pipelines.ActionStep>();
foreach (var step in newSteps)
{
var actionStep = new GitHub.DistributedTask.Pipelines.ActionStep
{
ContextName = step.Id,
};
if (step is GitHub.Actions.WorkflowParser.RunStep runStep)
{
actionStep.Condition = ExtractConditionString(runStep.If);
actionStep.DisplayNameToken = ConvertToLegacyToken<TemplateToken>(runStep.Name);
actionStep.ContinueOnError = ConvertToLegacyToken<TemplateToken>(runStep.ContinueOnError);
actionStep.TimeoutInMinutes = ConvertToLegacyToken<TemplateToken>(runStep.TimeoutMinutes);
actionStep.Environment = ConvertToLegacyToken<TemplateToken>(runStep.Env);
actionStep.Reference = new GitHub.DistributedTask.Pipelines.ScriptReference();
actionStep.Inputs = BuildRunStepInputs(runStep);
}
else if (step is GitHub.Actions.WorkflowParser.ActionStep usesStep)
{
actionStep.Condition = ExtractConditionString(usesStep.If);
actionStep.DisplayNameToken = ConvertToLegacyToken<TemplateToken>(usesStep.Name);
actionStep.ContinueOnError = ConvertToLegacyToken<TemplateToken>(usesStep.ContinueOnError);
actionStep.TimeoutInMinutes = ConvertToLegacyToken<TemplateToken>(usesStep.TimeoutMinutes);
actionStep.Environment = ConvertToLegacyToken<TemplateToken>(usesStep.Env);
actionStep.Reference = ParseActionReference(usesStep.Uses?.Value);
actionStep.Inputs = ConvertToLegacyToken<MappingToken>(usesStep.With);
}
result.Add(actionStep);
}
return result;
}
private string ExtractConditionString(GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens.BasicExpressionToken ifToken)
{
if (ifToken == null)
{
return null;
}
// The Expression property is internal, so we use ToString() which formats as "${{ expr }}"
// Then strip the delimiters to get just the expression
var str = ifToken.ToString();
if (str.StartsWith("${{") && str.EndsWith("}}"))
{
return str.Substring(3, str.Length - 5).Trim();
}
return str;
}
private MappingToken BuildRunStepInputs(GitHub.Actions.WorkflowParser.RunStep runStep)
{
var inputs = new MappingToken(null, null, null);
// script (from run)
if (runStep.Run != null)
{
inputs.Add(
new StringToken(null, null, null, "script"),
ConvertToLegacyToken<TemplateToken>(runStep.Run));
}
// shell
if (runStep.Shell != null)
{
inputs.Add(
new StringToken(null, null, null, "shell"),
ConvertToLegacyToken<TemplateToken>(runStep.Shell));
}
// working-directory
if (runStep.WorkingDirectory != null)
{
inputs.Add(
new StringToken(null, null, null, "workingDirectory"),
ConvertToLegacyToken<TemplateToken>(runStep.WorkingDirectory));
}
return inputs.Count > 0 ? inputs : null;
}
private GitHub.DistributedTask.Pipelines.ActionStepDefinitionReference ParseActionReference(string uses)
{
if (string.IsNullOrEmpty(uses))
{
return null;
}
// Docker reference: docker://image:tag
if (uses.StartsWith("docker://", StringComparison.OrdinalIgnoreCase))
{
return new GitHub.DistributedTask.Pipelines.ContainerRegistryReference
{
Image = uses.Substring("docker://".Length)
};
}
// Local path reference: ./path/to/action
if (uses.StartsWith("./") || uses.StartsWith(".\\"))
{
return new GitHub.DistributedTask.Pipelines.RepositoryPathReference
{
RepositoryType = "self",
Path = uses
};
}
// Repository reference: owner/repo@ref or owner/repo/path@ref
var atIndex = uses.LastIndexOf('@');
string refPart = null;
string repoPart = uses;
if (atIndex > 0)
{
refPart = uses.Substring(atIndex + 1);
repoPart = uses.Substring(0, atIndex);
}
// Split by / to get owner/repo and optional path
var parts = repoPart.Split('/');
string name;
string path = null;
if (parts.Length >= 2)
{
name = $"{parts[0]}/{parts[1]}";
if (parts.Length > 2)
{
path = string.Join("/", parts, 2, parts.Length - 2);
}
}
else
{
name = repoPart;
}
return new GitHub.DistributedTask.Pipelines.RepositoryPathReference
{
RepositoryType = "GitHub",
Name = name,
Ref = refPart,
Path = path
};
}
private T ConvertToLegacyToken<T>(GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens.TemplateToken newToken) where T : TemplateToken
@@ -633,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);
@@ -697,5 +847,6 @@ namespace GitHub.Runner.Worker
return messages;
}
}
}

View File

@@ -31,5 +31,7 @@ namespace GitHub.Runner.Worker
public JObject ContainerHookState { get; set; }
public bool HasTemplateEvaluatorMismatch { get; set; }
public bool HasActionManifestMismatch { get; set; }
public bool HasDeprecatedSetOutput { get; set; }
public bool HasDeprecatedSaveState { get; set; }
}
}

View File

@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("Test")]

View File

@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using GitHub.Actions.WorkflowParser;
using GitHub.DistributedTask.Expressions2;
@@ -216,12 +216,15 @@ namespace GitHub.Runner.Worker
}
}
private TLegacy EvaluateAndCompare<TLegacy, TNew>(
internal TLegacy EvaluateAndCompare<TLegacy, TNew>(
string methodName,
Func<TLegacy> legacyEvaluator,
Func<TNew> newEvaluator,
Func<TLegacy, TNew, bool> resultComparer)
{
// Capture cancellation state before evaluation
var cancellationRequestedBefore = _context.CancellationToken.IsCancellationRequested;
// Legacy evaluator
var legacyException = default(Exception);
var legacyResult = default(TLegacy);
@@ -253,14 +256,18 @@ namespace GitHub.Runner.Worker
newException = ex;
}
// Capture cancellation state after evaluation
var cancellationRequestedAfter = _context.CancellationToken.IsCancellationRequested;
// Compare results or exceptions
bool hasMismatch = false;
if (legacyException != null || newException != null)
{
// Either one or both threw exceptions - compare them
if (!CompareExceptions(legacyException, newException))
{
_trace.Info($"{methodName} exception mismatch");
RecordMismatch($"{methodName}");
hasMismatch = true;
}
}
else
@@ -269,6 +276,20 @@ namespace GitHub.Runner.Worker
if (!resultComparer(legacyResult, newResult))
{
_trace.Info($"{methodName} mismatch");
hasMismatch = true;
}
}
// Only record mismatch if it wasn't caused by a cancellation race condition
if (hasMismatch)
{
if (!cancellationRequestedBefore && cancellationRequestedAfter)
{
// Cancellation state changed during evaluation window - skip recording
_trace.Info($"{methodName} mismatch skipped due to cancellation race condition");
}
else
{
RecordMismatch($"{methodName}");
}
}
@@ -612,6 +633,13 @@ 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 (IsKnownEquivalentErrorPattern(legacyException, newException))
{
return true;
}
// Compare exception messages recursively (including inner exceptions)
var legacyMessages = GetExceptionMessages(legacyException);
var newMessages = GetExceptionMessages(newException);
@@ -634,6 +662,67 @@ namespace GitHub.Runner.Worker
return true;
}
/// <summary>
/// Checks if two exceptions match a known pattern where both parsers correctly reject
/// invalid input but with different error messages (e.g., JSON parse errors from fromJSON).
/// </summary>
private bool IsKnownEquivalentErrorPattern(Exception legacyException, Exception 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 (HasJsonExceptionType(legacyException) && HasJsonExceptionType(newException))
{
_trace.Info("CompareExceptions - both exceptions are JSON parse errors, treating as matched");
return true;
}
return false;
}
/// <summary>
/// Checks if the exception chain contains a JSON-related exception type.
/// </summary>
internal static bool HasJsonExceptionType(Exception ex)
{
var toProcess = new Queue<Exception>();
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<string> GetExceptionMessages(Exception ex)
{
var messages = new List<string>();

View File

@@ -24,7 +24,7 @@
<PackageReference Include="Microsoft.AspNet.WebApi.Client" Version="6.0.0" />
<PackageReference Include="System.Security.Cryptography.Cng" Version="5.0.0" />
<PackageReference Include="System.Security.Cryptography.Pkcs" Version="10.0.2" />
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="8.0.0" />
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="10.0.3" />
<PackageReference Include="Minimatch" Version="2.0.0" />
<PackageReference Include="YamlDotNet.Signed" Version="5.3.0" />
<PackageReference Include="System.Net.Http" Version="4.3.4" />

View File

@@ -1,4 +1,4 @@
#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references
#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references
using System;
using System.Collections.Generic;
@@ -43,7 +43,7 @@ namespace GitHub.Actions.WorkflowParser.Conversion
{
case WorkflowTemplateConstants.On:
var inputTypes = ConvertToOnWorkflowDispatchInputTypes(workflowPair.Value);
foreach(var item in inputTypes)
foreach (var item in inputTypes)
{
result.InputTypes.TryAdd(item.Key, item.Value);
}
@@ -432,7 +432,7 @@ namespace GitHub.Actions.WorkflowParser.Conversion
context.Error(snapshotToken, $"job {WorkflowTemplateConstants.Snapshot} {WorkflowTemplateConstants.ImageName} is required.");
return null;
}
return new Snapshot
{
ImageName = imageName,
@@ -445,7 +445,7 @@ namespace GitHub.Actions.WorkflowParser.Conversion
{
var versionSegments = versionString.Split(".");
if (versionSegments.Length != 2 ||
if (versionSegments.Length != 2 ||
!versionSegments[1].Equals("*") ||
!Int32.TryParse(versionSegments[0], NumberStyles.None, CultureInfo.InvariantCulture, result: out int parsedMajor) ||
parsedMajor < 0)
@@ -1154,7 +1154,12 @@ namespace GitHub.Actions.WorkflowParser.Conversion
if (String.IsNullOrEmpty(result.Image))
{
context.Error(value, "Container image cannot be empty");
// Only error during early validation (parse time)
// At runtime (expression evaluation), empty image = no container
if (isEarlyValidation)
{
context.Error(value, "Container image cannot be empty");
}
return null;
}
@@ -1838,9 +1843,9 @@ namespace GitHub.Actions.WorkflowParser.Conversion
case "actions":
permissions.Actions = permissionLevel;
break;
case "artifact-metadata":
permissions.ArtifactMetadata = permissionLevel;
break;
case "artifact-metadata":
permissions.ArtifactMetadata = permissionLevel;
break;
case "attestations":
permissions.Attestations = permissionLevel;
break;

View File

@@ -739,7 +739,8 @@ namespace GitHub.Runner.Common.Tests.Listener
Assert.True(jobDispatcher.RunOnceJobCompleted.Task.IsCompleted, "JobDispatcher should set task complete token for one time agent.");
if (jobDispatcher.RunOnceJobCompleted.Task.IsCompleted)
{
Assert.True(await jobDispatcher.RunOnceJobCompleted.Task, "JobDispatcher should set task complete token to 'TRUE' for one time agent.");
var result = await jobDispatcher.RunOnceJobCompleted.Task;
Assert.Equal(TaskResult.Succeeded, result);
}
}
}

View File

@@ -295,13 +295,13 @@ namespace GitHub.Runner.Common.Tests.Listener
_messageListener.Setup(x => x.DeleteMessageAsync(It.IsAny<TaskAgentMessage>()))
.Returns(Task.CompletedTask);
var runOnceJobCompleted = new TaskCompletionSource<bool>();
var runOnceJobCompleted = new TaskCompletionSource<TaskResult>();
_jobDispatcher.Setup(x => x.RunOnceJobCompleted)
.Returns(runOnceJobCompleted);
_jobDispatcher.Setup(x => x.Run(It.IsAny<Pipelines.AgentJobRequestMessage>(), It.IsAny<bool>()))
.Callback(() =>
{
runOnceJobCompleted.TrySetResult(true);
runOnceJobCompleted.TrySetResult(TaskResult.Succeeded);
});
_jobNotification.Setup(x => x.StartClient(It.IsAny<String>()))
.Callback(() =>
@@ -399,13 +399,13 @@ namespace GitHub.Runner.Common.Tests.Listener
_messageListener.Setup(x => x.DeleteMessageAsync(It.IsAny<TaskAgentMessage>()))
.Returns(Task.CompletedTask);
var runOnceJobCompleted = new TaskCompletionSource<bool>();
var runOnceJobCompleted = new TaskCompletionSource<TaskResult>();
_jobDispatcher.Setup(x => x.RunOnceJobCompleted)
.Returns(runOnceJobCompleted);
_jobDispatcher.Setup(x => x.Run(It.IsAny<Pipelines.AgentJobRequestMessage>(), It.IsAny<bool>()))
.Callback(() =>
{
runOnceJobCompleted.TrySetResult(true);
runOnceJobCompleted.TrySetResult(TaskResult.Succeeded);
});
_jobNotification.Setup(x => x.StartClient(It.IsAny<String>()))
.Callback(() =>
@@ -733,8 +733,8 @@ namespace GitHub.Runner.Common.Tests.Listener
_configStore.Setup(x => x.IsServiceConfigured()).Returns(false);
var completedTask = new TaskCompletionSource<bool>();
completedTask.SetResult(true);
var completedTask = new TaskCompletionSource<TaskResult>();
completedTask.SetResult(TaskResult.Succeeded);
_jobDispatcher.Setup(x => x.RunOnceJobCompleted).Returns(completedTask);
//Act
@@ -834,8 +834,8 @@ namespace GitHub.Runner.Common.Tests.Listener
_configStore.Setup(x => x.IsServiceConfigured()).Returns(false);
var completedTask = new TaskCompletionSource<bool>();
completedTask.SetResult(true);
var completedTask = new TaskCompletionSource<TaskResult>();
completedTask.SetResult(TaskResult.Succeeded);
_jobDispatcher.Setup(x => x.RunOnceJobCompleted).Returns(completedTask);
//Act
@@ -954,8 +954,8 @@ namespace GitHub.Runner.Common.Tests.Listener
_configStore.Setup(x => x.IsServiceConfigured()).Returns(false);
var completedTask = new TaskCompletionSource<bool>();
completedTask.SetResult(true);
var completedTask = new TaskCompletionSource<TaskResult>();
completedTask.SetResult(TaskResult.Succeeded);
_jobDispatcher.Setup(x => x.RunOnceJobCompleted).Returns(completedTask);
//Act

View File

@@ -457,6 +457,8 @@ namespace GitHub.Runner.Common.Tests.Worker
new SetEnvCommandExtension(),
new WarningCommandExtension(),
new AddMaskCommandExtension(),
new SetOutputCommandExtension(),
new SaveStateCommandExtension(),
};
foreach (var command in commands)
{
@@ -499,5 +501,53 @@ namespace GitHub.Runner.Common.Tests.Worker
};
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void SetOutputCommand_EmitsTelemetryOnce()
{
using (TestHostContext hc = CreateTestContext())
{
_ec.Object.Global.JobTelemetry = new List<JobTelemetry>();
var reference = string.Empty;
_ec.Setup(x => x.SetOutput(It.IsAny<string>(), It.IsAny<string>(), out reference));
// First set-output should add telemetry
Assert.True(_commandManager.TryProcessCommand(_ec.Object, "::set-output name=foo::bar", null));
Assert.Single(_ec.Object.Global.JobTelemetry);
Assert.Equal(JobTelemetryType.ActionCommand, _ec.Object.Global.JobTelemetry[0].Type);
Assert.Equal("DeprecatedCommand: set-output", _ec.Object.Global.JobTelemetry[0].Message);
Assert.True(_ec.Object.Global.HasDeprecatedSetOutput);
// Second set-output should not add another telemetry entry
Assert.True(_commandManager.TryProcessCommand(_ec.Object, "::set-output name=foo2::bar2", null));
Assert.Single(_ec.Object.Global.JobTelemetry);
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void SaveStateCommand_EmitsTelemetryOnce()
{
using (TestHostContext hc = CreateTestContext())
{
_ec.Object.Global.JobTelemetry = new List<JobTelemetry>();
_ec.Setup(x => x.IsEmbedded).Returns(false);
_ec.Setup(x => x.IntraActionState).Returns(new Dictionary<string, string>());
// First save-state should add telemetry
Assert.True(_commandManager.TryProcessCommand(_ec.Object, "::save-state name=foo::bar", null));
Assert.Single(_ec.Object.Global.JobTelemetry);
Assert.Equal(JobTelemetryType.ActionCommand, _ec.Object.Global.JobTelemetry[0].Type);
Assert.Equal("DeprecatedCommand: save-state", _ec.Object.Global.JobTelemetry[0].Message);
Assert.True(_ec.Object.Global.HasDeprecatedSaveState);
// Second save-state should not add another telemetry entry
Assert.True(_commandManager.TryProcessCommand(_ec.Object, "::save-state name=foo2::bar2", null));
Assert.Single(_ec.Object.Global.JobTelemetry);
}
}
}
}

View File

@@ -0,0 +1,424 @@
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
{
/// <summary>
/// Tests for parser comparison wrapper classes.
/// </summary>
public sealed class ActionManifestParserComparisonL0
{
private CancellationTokenSource _ecTokenSource;
private Mock<IExecutionContext> _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<IActionManifestManagerLegacy>(legacyManager);
var newManager = new ActionManifestManager();
newManager.Initialize(_hc);
_hc.SetSingleton<IActionManifestManager>(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<GitHub.DistributedTask.Pipelines.ScriptReference>(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<string>();
// 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<LegacyExpressions.IFunctionInfo>();
// 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<GitHub.Actions.Expressions.IFunctionInfo>();
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<string>();
// 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<LegacyExpressions.IFunctionInfo>();
var newExpressionFunctions = new List<GitHub.Actions.Expressions.IFunctionInfo>();
// 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 EvaluateDefaultInput_BothParsersAgree()
{
try
{
Setup();
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
var legacyManager = new ActionManifestManagerLegacy();
legacyManager.Initialize(_hc);
_hc.SetSingleton<IActionManifestManagerLegacy>(legacyManager);
var newManager = new ActionManifestManager();
newManager.Initialize(_hc);
_hc.SetSingleton<IActionManifestManager>(newManager);
var wrapper = new ActionManifestManagerWrapper();
wrapper.Initialize(_hc);
_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<GitHub.Runner.Worker.Expressions.HashFilesFunction>("hashFiles", 1, 255));
var result = wrapper.EvaluateDefaultInput(_ec.Object, "testInput", new StringToken(null, null, null, "defaultValue"));
Assert.Equal("defaultValue", result);
Assert.False(_ec.Object.Global.HasActionManifestMismatch);
}
finally
{
Teardown();
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public void EvaluateContainerArguments_BothParsersAgree()
{
try
{
Setup();
_ec.Object.Global.Variables.Set(Constants.Runner.Features.CompareWorkflowParser, "true");
var legacyManager = new ActionManifestManagerLegacy();
legacyManager.Initialize(_hc);
_hc.SetSingleton<IActionManifestManagerLegacy>(legacyManager);
var newManager = new ActionManifestManager();
newManager.Initialize(_hc);
_hc.SetSingleton<IActionManifestManager>(newManager);
var wrapper = new ActionManifestManagerWrapper();
wrapper.Initialize(_hc);
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<string, LegacyContextData.PipelineContextData>(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<IActionManifestManagerLegacy>(legacyManager);
var newManager = new ActionManifestManager();
newManager.Initialize(_hc);
_hc.SetSingleton<IActionManifestManager>(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<string, LegacyContextData.PipelineContextData>(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<IActionManifestManagerLegacy>(legacyManager);
var newManager = new ActionManifestManager();
newManager.Initialize(_hc);
_hc.SetSingleton<IActionManifestManager>(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<string, LegacyContextData.PipelineContextData>(StringComparer.OrdinalIgnoreCase);
var result = wrapper.EvaluateCompositeOutputs(_ec.Object, token, evaluateContext);
Assert.NotNull(result);
Assert.False(_ec.Object.Global.HasActionManifestMismatch);
}
finally
{
Teardown();
}
}
private string GetFullExceptionMessage(Exception ex)
{
var messages = new List<string>();
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<LegacyExpressions.IFunctionInfo>();
_ec = new Mock<IExecutionContext>();
_ec.Setup(x => x.Global)
.Returns(new GlobalContext
{
FileTable = new List<String>(),
Variables = new Variables(_hc, new Dictionary<string, VariableValue>()),
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<string>(), It.IsAny<string>())).Callback((string tag, string message) => { _hc.GetTrace().Info($"{tag}{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()
{
_hc?.Dispose();
}
}
}

View File

@@ -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<IExecutionContext> _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<LegacyExpressions.IFunctionInfo>();
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<string, string>(
"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<string, string>(
"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<LegacyExpressions.IFunctionInfo>();
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<LegacyExpressions.IFunctionInfo>();
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<LegacyExpressions.IFunctionInfo>();
var expressionState = new List<KeyValuePair<string, object>>();
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<LegacyExpressions.IFunctionInfo>();
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<LegacyExpressions.IFunctionInfo>();
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<LegacyExpressions.IFunctionInfo>();
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<LegacyExpressions.IFunctionInfo>();
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<LegacyExpressions.IFunctionInfo>();
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<LegacyExpressions.IFunctionInfo>();
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<LegacyExpressions.IFunctionInfo>();
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<LegacyExpressions.IFunctionInfo>();
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<Newtonsoft.Json.JsonReaderException>(() =>
wrapper.EvaluateAndCompare<string, string>(
"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<Newtonsoft.Json.JsonReaderException>(() =>
wrapper.EvaluateAndCompare<string, string>(
"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<InvalidOperationException>(() =>
wrapper.EvaluateAndCompare<string, string>(
"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<LegacyExpressions.IFunctionInfo>();
_ec = new Mock<IExecutionContext>();
_ec.Setup(x => x.Global)
.Returns(new GlobalContext
{
FileTable = new List<String>(),
Variables = new Variables(_hc, new Dictionary<string, VariableValue>()),
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<string>(), It.IsAny<string>())).Callback((string tag, string message) => { _hc.GetTrace().Info($"{tag}{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()
{
_hc?.Dispose();
}
}
}