Update nullable argument defaults and enhance ForEach tests with additional edge cases. (#6928)

* Update nullable argument defaults and enhance ForEach tests with additional edge cases.

* Refactor input evaluation and JavaScript evaluator for clarity and consistency.

* Update src/modules/Elsa.Workflows.Core/Extensions/ActivityExecutionContextExtensions.InputEvaluation.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Sipke Schoorstra 2025-09-27 17:50:19 +02:00 committed by GitHub
parent bc4e4d60ee
commit 003e88c4da
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 280 additions and 35 deletions

View file

@ -11,7 +11,7 @@ public class DelegateExpressionHandler : IExpressionHandler
/// <inheritdoc />
public async ValueTask<object?> EvaluateAsync(Expression expression, Type returnType, ExpressionExecutionContext context, ExpressionEvaluatorOptions options)
{
var value = expression.Value is Func<ExpressionExecutionContext, ValueTask<object?>> @delegate ? await @delegate(context) : default;
var value = expression.Value is Func<ExpressionExecutionContext, ValueTask<object?>> @delegate ? await @delegate(context) : null;
return value;
}
}

View file

@ -11,7 +11,7 @@ public class Literal : MemoryBlockReference
}
/// <inheritdoc />
public Literal(object? value, string? id = default) : base(id!)
public Literal(object? value, string? id = null) : base(id!)
{
Value = value;
}
@ -42,7 +42,7 @@ public class Literal<T> : Literal
}
/// <inheritdoc />
public Literal(T value, string? id = default) : base(value!, id)
public Literal(T value, string? id = null) : base(value!, id)
{
}
}

View file

@ -8,7 +8,7 @@ public class MemoryRegister
/// <summary>
/// Constructor.
/// </summary>
public MemoryRegister(IDictionary<string, MemoryBlock>? blocks = default)
public MemoryRegister(IDictionary<string, MemoryBlock>? blocks = null)
{
Blocks = blocks ?? new Dictionary<string, MemoryBlock>();
}

View file

@ -50,7 +50,7 @@ public class JintJavaScriptEvaluator(IConfiguration configuration, INotification
var engineOptions = new Jint.Options
{
ExperimentalFeatures = ExperimentalFeature.TaskInterop,
ExperimentalFeatures = ExperimentalFeature.TaskInterop
};
ConfigureClrAccess(engineOptions);

View file

@ -754,12 +754,6 @@ public partial class ActivityExecutionContext : IExecutionContext, IDisposable
return true;
}
if (blockReference is Literal literal)
{
value = literal.Value;
return true;
}
value = null;
return false;
}

View file

@ -118,9 +118,12 @@ public static partial class ActivityExecutionContextExtensions
var memoryReference = wrappedInput?.MemoryBlockReference();
// When input is created from an activity provider, there may be no memory block reference.
if (memoryReference?.Id != null!)
if (memoryReference != null)
{
// When input is created from an activity provider, there may be no memory block reference ID.
if (memoryReference.Id == null!)
memoryReference.Id = $"{activity.NodeId}.{inputDescriptor.Name}"; // Construct a deterministic ID.
// Declare the input memory block in the current context.
context.ExpressionExecutionContext.Set(memoryReference, value!);
}

View file

@ -33,5 +33,5 @@ public abstract class Argument
/// Gets or sets the memory block reference.
/// </summary>
[JsonIgnore]
public Func<MemoryBlockReference> MemoryBlockReference { get; set; } = default!;
public Func<MemoryBlockReference> MemoryBlockReference { get; set; } = null!;
}

View file

@ -45,37 +45,37 @@ public class Input<T> : Input
}
/// <inheritdoc />
public Input(T literal, string? id = default) : this(new Literal<T>(literal, id))
public Input(T literal, string? id = null) : this(new Literal<T>(literal, id))
{
}
/// <inheritdoc />
public Input(Func<T> @delegate, string? id = default) : this(Expression.DelegateExpression(@delegate), new MemoryBlockReference(id!))
public Input(Func<T> @delegate, string? id = null) : this(Expression.DelegateExpression(@delegate), new(id!))
{
}
/// <inheritdoc />
public Input(Func<ExpressionExecutionContext, ValueTask<T?>> @delegate, string? id = default) : this(Expression.DelegateExpression(@delegate), new MemoryBlockReference(id!))
public Input(Func<ExpressionExecutionContext, ValueTask<T?>> @delegate, string? id = null) : this(Expression.DelegateExpression(@delegate), new(id!))
{
}
/// <inheritdoc />
public Input(Func<ValueTask<T?>> @delegate, string? id = default) : this(Expression.DelegateExpression(@delegate), new MemoryBlockReference(id!))
public Input(Func<ValueTask<T?>> @delegate, string? id = null) : this(Expression.DelegateExpression(@delegate), new(id!))
{
}
/// <inheritdoc />
public Input(Func<ExpressionExecutionContext, T> @delegate, string? id = default) : this(Expression.DelegateExpression(@delegate), new MemoryBlockReference(id!))
public Input(Func<ExpressionExecutionContext, T> @delegate, string? id = null) : this(Expression.DelegateExpression(@delegate), new(id!))
{
}
/// <inheritdoc />
public Input(Variable variable) : base(new Expression("Variable", variable), variable, typeof(T))
public Input(Variable variable) : base(new("Variable", variable), variable, typeof(T))
{
}
/// <inheritdoc />
public Input(Output output) : base(new Expression("Output", output), output.MemoryBlockReference(), typeof(T))
public Input(Output output) : base(new("Output", output), output.MemoryBlockReference(), typeof(T))
{
}
@ -105,7 +105,7 @@ public class Input<T> : Input
}
/// <inheritdoc />
public Input(Expression expression) : this(expression, new MemoryBlockReference())
public Input(Expression expression) : this(expression, new())
{
}
}

View file

@ -6,9 +6,14 @@ namespace Elsa.Workflows.Models;
/// <summary>
/// Contains information about a workflow run, such as <see cref="WorkflowState"/>.
/// </summary>
public record RunWorkflowResult(WorkflowExecutionContext WorkflowExecutionContext, WorkflowState WorkflowState, Workflow Workflow, object? Result);
public record RunWorkflowResult(WorkflowExecutionContext WorkflowExecutionContext, WorkflowState WorkflowState, Workflow Workflow, object? Result, Journal Journal);
/// <summary>
/// Contains information about a workflow run, such as <see cref="WorkflowState"/>.
/// </summary>
public record RunWorkflowResult<TResult>(WorkflowExecutionContext WorkflowExecutionContext, WorkflowState WorkflowState, Workflow Workflow, TResult Result);
public record RunWorkflowResult<TResult>(WorkflowExecutionContext WorkflowExecutionContext, WorkflowState WorkflowState, Workflow Workflow, TResult Result, Journal Journal);
public record Journal(ICollection<WorkflowExecutionLogEntry> WorkflowExecutionLogEntries, ICollection<ActivityExecutionContext> ActivityExecutionContexts)
{
public static Journal Empty => new([], []);
}

View file

@ -45,7 +45,7 @@ public class WorkflowRunner(
public async Task<RunWorkflowResult<TResult>> RunAsync<TResult>(WorkflowBase<TResult> workflow, RunWorkflowOptions? options = null, CancellationToken cancellationToken = default)
{
var result = await RunAsync((IWorkflow)workflow, options, cancellationToken);
return new(result.WorkflowExecutionContext, result.WorkflowState, result.Workflow, (TResult)result.Result!);
return new(result.WorkflowExecutionContext, result.WorkflowState, result.Workflow, (TResult)result.Result!, result.Journal);
}
/// <inheritdoc />
@ -217,8 +217,11 @@ public class WorkflowRunner(
}
var result = workflow.ResultVariable?.Get(workflowExecutionContext.MemoryRegister);
var workflowExecutionLogEntries = workflowExecutionContext.ExecutionLog.ToList();
var activityExecutionContexts = workflowExecutionContext.ActivityExecutionContexts.ToList();
var journal = new Journal(workflowExecutionLogEntries, activityExecutionContexts);
await notificationSender.SendAsync(new WorkflowExecuted(workflow, workflowState, workflowExecutionContext), cancellationToken);
await commitStateHandler.CommitAsync(workflowExecutionContext, workflowState, cancellationToken);
return new(workflowExecutionContext, workflowState, workflowExecutionContext.Workflow, result);
return new(workflowExecutionContext, workflowState, workflowExecutionContext.Workflow, result, journal);
}
}

View file

@ -201,7 +201,7 @@ internal class WorkflowInstance(
if (_isRunning)
{
_queuedRunWorkflowOptions.Enqueue(runWorkflowOptions);
return new(null!, null!, null!, null);
return new(null!, null!, null!, null, Journal.Empty);
}
_isRunning = true;

View file

@ -66,12 +66,12 @@ public class WorkflowHost : IWorkflowHost
}
/// <inheritdoc />
public async Task<RunWorkflowResult> RunWorkflowAsync(RunWorkflowOptions? @params = default, CancellationToken cancellationToken = default)
public async Task<RunWorkflowResult> RunWorkflowAsync(RunWorkflowOptions? @params = null, CancellationToken cancellationToken = default)
{
if (WorkflowState.Status != WorkflowStatus.Running)
{
_logger.LogWarning("Attempt to resume workflow {WorkflowInstanceId} that is not in the Running state. The actual state is {ActualWorkflowStatus}", WorkflowState.Id, WorkflowState.Status);
return new RunWorkflowResult(null!, WorkflowState, Workflow, null);
return new(null!, WorkflowState, Workflow, null, Journal.Empty);
}
var runOptions = new RunWorkflowOptions
@ -85,7 +85,7 @@ public class WorkflowHost : IWorkflowHost
TriggerActivityId = @params?.TriggerActivityId,
ParentWorkflowInstanceId = @params?.ParentWorkflowInstanceId
};
_linkedTokenSource = new CancellationTokenSource();
_linkedTokenSource = new();
var linkedCancellationToken = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _linkedTokenSource.Token).Token;
await using var scope = _serviceScopeFactory.CreateAsyncScope();

View file

@ -1,6 +1,8 @@
using Elsa.Extensions;
using Elsa.Testing.Shared;
using Elsa.Workflows;
using Elsa.Workflows.Activities;
using Elsa.Workflows.Models;
using Xunit.Abstractions;
namespace Elsa.Activities.IntegrationTests;
@ -9,21 +11,259 @@ public class ForEachTests
{
private readonly CapturingTextWriter _capturingTextWriter = new();
private readonly IServiceProvider _serviceProvider;
private const string CurrentValueVar = "CurrentValue";
public ForEachTests(ITestOutputHelper testOutputHelper)
{
_serviceProvider = new TestApplicationBuilder(testOutputHelper).WithCapturingTextWriter(_capturingTextWriter).Build();
}
[Fact(DisplayName = "ForEach executes each activity for every item in the collection")]
public async Task ForEach_ExecutesEachActivity_ForEveryItem()
{
var expectedLines = new[] {"a", "b", "c"};
var expectedLines = new[]
{
"a", "b", "c"
};
var forEach = new ForEach<string>(expectedLines)
{
Body = new WriteLine(context => context.GetVariable<string>("CurrentValue"))
Body = WriteCurrentValue()
};
await _serviceProvider.RunActivityAsync(forEach);
Assert.Equal(expectedLines, _capturingTextWriter.Lines);
await RunAndAssertLines(forEach, expectedLines);
}
[Fact(DisplayName = "ForEach executes each activity for every item in the collection even if there's only one item")]
public async Task ForEach_ExecutesEachActivity_ForSingleItem()
{
var expectedLines = new[]
{
"a"
};
var forEach = new ForEach<string>(expectedLines)
{
Body = WriteCurrentValue()
};
await RunAndAssertLines(forEach, expectedLines);
}
[Fact(DisplayName = "ForEach completes when the collection is empty")]
public async Task ForEach_Completes_WhenCollectionIsEmpty()
{
string[] expectedLines = [];
var forEach = new ForEach<string>(expectedLines)
{
Body = WriteCurrentValue()
};
var result = await _serviceProvider.RunActivityAsync(forEach);
var journal = result.Journal;
var forEachContext = journal.ActivityExecutionContexts.FirstOrDefault(x => x.Activity is ForEach<string>);
Assert.NotNull(forEachContext);
Assert.Equal(ActivityStatus.Completed, forEachContext.Status);
}
[Fact(DisplayName = "ForEach faults when the collection is null")]
public async Task ForEach_Faults_WhenCollectionIsNull()
{
var forEach = new ForEach<string>((ICollection<string>)null!)
{
Body = WriteCurrentValue()
};
var result = await _serviceProvider.RunActivityAsync(forEach);
var journal = result.Journal;
var forEachContext = journal.ActivityExecutionContexts.FirstOrDefault(x => x.Activity is ForEach<string>);
Assert.NotNull(forEachContext);
Assert.Equal(ActivityStatus.Faulted, forEachContext.Status);
}
[Fact(DisplayName = "ForEach breaks when the Break activity executed")]
public async Task ForEach_BreaksOutOfLoop_WhenExecutingBreakActivity()
{
var dataSource = new[]
{
"a", "b", "c"
};
var expectedLines = new[]
{
"a"
};
var forEach = new ForEach<string>(dataSource)
{
Body = new If
{
Condition = new(context => context.GetVariable<string>(CurrentValueVar) == "b"),
Then = new Break(),
Else = WriteCurrentValue()
}
};
await RunAndAssertLines(forEach, expectedLines);
}
[Fact(DisplayName = "ForEach executes each activity for different item types")]
public async Task ForEach_ExecutesEachActivity_ForDifferentItemTypes()
{
var dataSource = new object?[]
{
"a", 2, null, new Foo()
};
var expectedLines = new object[]
{
"a", "2", "", "Baz"
};
var forEach = new ForEach<object?>(dataSource)
{
Body = new WriteLine(context => context.GetVariable<object>(CurrentValueVar)?.ToString() ?? "")
};
await RunAndAssertLines(forEach, expectedLines);
}
[Fact(DisplayName = "ForEach completes when the end of the collection is reached even when an item is added to the collection at runtime")]
public async Task ForEach_Completes_WhenItemAddedToCollectionAtRuntime()
{
var dataSource = new[]
{
"a", "b", "c"
}.ToList();
var expectedLines = new[]
{
"a", "b", "c", "e"
};
var dataSourceInput = new Input<ICollection<string>>(() => dataSource.ToList());
var forEach = new ForEach<string>(dataSourceInput)
{
Body = new Sequence
{
Activities =
[
new If(context => context.GetVariable<string>(CurrentValueVar) == "b")
{
Then = new Inline(inlineContext =>
{
dataSource.Add("e");
inlineContext.Set(dataSourceInput.MemoryBlockReference(), dataSource.ToList());
})
},
WriteCurrentValue()
]
}
};
await RunAndAssertLines(forEach, expectedLines);
}
[Fact(DisplayName = "ForEach completes when the end of the collection is reached even when an item is removed from the collection at runtime")]
public async Task ForEach_Completes_WhenItemRemovedFromCollectionAtRuntime()
{
var dataSource = new[]
{
"a", "b", "c"
}.ToList();
var expectedLines = new[]
{
"a", "b"
};
var dataSourceInput = new Input<ICollection<string>>(() => dataSource.ToList());
var forEach = new ForEach<string>(dataSourceInput)
{
Body = new Sequence
{
Activities =
[
new If(context => context.GetVariable<string>(CurrentValueVar) == "a")
{
Then = new Inline(inlineContext =>
{
dataSource.Remove("c");
inlineContext.Set(dataSourceInput.MemoryBlockReference(), dataSource.ToList());
})
},
WriteCurrentValue()
]
}
};
await RunAndAssertLines(forEach, expectedLines);
}
[Fact(DisplayName = "ForEach completes when the end of the collection is reached even when an item is changed at runtime")]
public async Task ForEach_Completes_WhenItemChangedAtRuntime()
{
var dataSource = new[]
{
"a", "b", "c"
}.ToList();
var expectedLines = new[]
{
"a", "b", "d"
};
var dataSourceInput = new Input<ICollection<string>>(() => dataSource.ToList());
var forEach = new ForEach<string>(dataSourceInput)
{
Body = new Sequence
{
Activities =
[
new If(context => context.GetVariable<string>(CurrentValueVar) == "b")
{
Then = new Inline(inlineContext =>
{
dataSource.Remove("c");
dataSource.Add("d");
inlineContext.Set(dataSourceInput.MemoryBlockReference(), dataSource.ToList());
})
},
WriteCurrentValue()
]
}
};
await RunAndAssertLines(forEach, expectedLines);
}
[Fact(DisplayName = "ForEach remains in the Running state when an activity faults")]
public async Task ForEach_Suspends_WhenActivityFaults()
{
var dataSource = new[]
{
"a", "b", "c"
}.ToList();
var expectedLines = new[]
{
"a", "b"
};
var forEach = new ForEach<string>(dataSource)
{
Body = new Sequence
{
Activities =
[
new If(context => context.GetVariable<string>(CurrentValueVar) == "c")
{
Then = new Fault
{
Message = new("Faulted"),
}
},
WriteCurrentValue()
]
}
};
var result = await _serviceProvider.RunActivityAsync(forEach);
var journal = result.Journal;
var forEachContext = journal.ActivityExecutionContexts.FirstOrDefault(x => x.Activity is ForEach<string>);
Assert.Equal(expectedLines, _capturingTextWriter.Lines);
Assert.NotNull(forEachContext);
Assert.Equal(ActivityStatus.Running, forEachContext.Status);
Assert.Equal(1, forEachContext.AggregateFaultCount);
}
private static WriteLine WriteCurrentValue() => new(context => context.GetVariable<string>(CurrentValueVar));
private async Task RunAndAssertLines(IActivity activity, System.Collections.IEnumerable expected)
{
await _serviceProvider.RunActivityAsync(activity);
Assert.Equal(expected, _capturingTextWriter.Lines);
}
}
record Foo(string Bar = "Baz")
{
public override string ToString() => Bar;
}