diff --git a/src/modules/Elsa.Expressions/DelegateExpressionHandler.cs b/src/modules/Elsa.Expressions/DelegateExpressionHandler.cs index 720212562..ac5960776 100644 --- a/src/modules/Elsa.Expressions/DelegateExpressionHandler.cs +++ b/src/modules/Elsa.Expressions/DelegateExpressionHandler.cs @@ -11,7 +11,7 @@ public class DelegateExpressionHandler : IExpressionHandler /// public async ValueTask EvaluateAsync(Expression expression, Type returnType, ExpressionExecutionContext context, ExpressionEvaluatorOptions options) { - var value = expression.Value is Func> @delegate ? await @delegate(context) : default; + var value = expression.Value is Func> @delegate ? await @delegate(context) : null; return value; } } \ No newline at end of file diff --git a/src/modules/Elsa.Expressions/Models/Literal.cs b/src/modules/Elsa.Expressions/Models/Literal.cs index f57527021..93792deb3 100644 --- a/src/modules/Elsa.Expressions/Models/Literal.cs +++ b/src/modules/Elsa.Expressions/Models/Literal.cs @@ -11,7 +11,7 @@ public class Literal : MemoryBlockReference } /// - 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 : Literal } /// - public Literal(T value, string? id = default) : base(value!, id) + public Literal(T value, string? id = null) : base(value!, id) { } } \ No newline at end of file diff --git a/src/modules/Elsa.Expressions/Models/MemoryRegister.cs b/src/modules/Elsa.Expressions/Models/MemoryRegister.cs index 07fdc3aca..bfe6e2db2 100644 --- a/src/modules/Elsa.Expressions/Models/MemoryRegister.cs +++ b/src/modules/Elsa.Expressions/Models/MemoryRegister.cs @@ -8,7 +8,7 @@ public class MemoryRegister /// /// Constructor. /// - public MemoryRegister(IDictionary? blocks = default) + public MemoryRegister(IDictionary? blocks = null) { Blocks = blocks ?? new Dictionary(); } diff --git a/src/modules/Elsa.JavaScript/Services/JintJavaScriptEvaluator.cs b/src/modules/Elsa.JavaScript/Services/JintJavaScriptEvaluator.cs index 256b86bae..bac7c36ac 100644 --- a/src/modules/Elsa.JavaScript/Services/JintJavaScriptEvaluator.cs +++ b/src/modules/Elsa.JavaScript/Services/JintJavaScriptEvaluator.cs @@ -50,7 +50,7 @@ public class JintJavaScriptEvaluator(IConfiguration configuration, INotification var engineOptions = new Jint.Options { - ExperimentalFeatures = ExperimentalFeature.TaskInterop, + ExperimentalFeatures = ExperimentalFeature.TaskInterop }; ConfigureClrAccess(engineOptions); diff --git a/src/modules/Elsa.Workflows.Core/Contexts/ActivityExecutionContext.cs b/src/modules/Elsa.Workflows.Core/Contexts/ActivityExecutionContext.cs index b08a72c74..10a123e64 100644 --- a/src/modules/Elsa.Workflows.Core/Contexts/ActivityExecutionContext.cs +++ b/src/modules/Elsa.Workflows.Core/Contexts/ActivityExecutionContext.cs @@ -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; } diff --git a/src/modules/Elsa.Workflows.Core/Extensions/ActivityExecutionContextExtensions.InputEvaluation.cs b/src/modules/Elsa.Workflows.Core/Extensions/ActivityExecutionContextExtensions.InputEvaluation.cs index acd1f8541..aee9509a3 100644 --- a/src/modules/Elsa.Workflows.Core/Extensions/ActivityExecutionContextExtensions.InputEvaluation.cs +++ b/src/modules/Elsa.Workflows.Core/Extensions/ActivityExecutionContextExtensions.InputEvaluation.cs @@ -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!); } diff --git a/src/modules/Elsa.Workflows.Core/Models/Argument.cs b/src/modules/Elsa.Workflows.Core/Models/Argument.cs index 938d54b54..bcc030d40 100644 --- a/src/modules/Elsa.Workflows.Core/Models/Argument.cs +++ b/src/modules/Elsa.Workflows.Core/Models/Argument.cs @@ -33,5 +33,5 @@ public abstract class Argument /// Gets or sets the memory block reference. /// [JsonIgnore] - public Func MemoryBlockReference { get; set; } = default!; + public Func MemoryBlockReference { get; set; } = null!; } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Models/Input.cs b/src/modules/Elsa.Workflows.Core/Models/Input.cs index c1d9b4665..0afeb5e76 100644 --- a/src/modules/Elsa.Workflows.Core/Models/Input.cs +++ b/src/modules/Elsa.Workflows.Core/Models/Input.cs @@ -45,37 +45,37 @@ public class Input : Input } /// - public Input(T literal, string? id = default) : this(new Literal(literal, id)) + public Input(T literal, string? id = null) : this(new Literal(literal, id)) { } /// - public Input(Func @delegate, string? id = default) : this(Expression.DelegateExpression(@delegate), new MemoryBlockReference(id!)) + public Input(Func @delegate, string? id = null) : this(Expression.DelegateExpression(@delegate), new(id!)) { } /// - public Input(Func> @delegate, string? id = default) : this(Expression.DelegateExpression(@delegate), new MemoryBlockReference(id!)) + public Input(Func> @delegate, string? id = null) : this(Expression.DelegateExpression(@delegate), new(id!)) { } /// - public Input(Func> @delegate, string? id = default) : this(Expression.DelegateExpression(@delegate), new MemoryBlockReference(id!)) + public Input(Func> @delegate, string? id = null) : this(Expression.DelegateExpression(@delegate), new(id!)) { } /// - public Input(Func @delegate, string? id = default) : this(Expression.DelegateExpression(@delegate), new MemoryBlockReference(id!)) + public Input(Func @delegate, string? id = null) : this(Expression.DelegateExpression(@delegate), new(id!)) { } /// - public Input(Variable variable) : base(new Expression("Variable", variable), variable, typeof(T)) + public Input(Variable variable) : base(new("Variable", variable), variable, typeof(T)) { } /// - 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 : Input } /// - public Input(Expression expression) : this(expression, new MemoryBlockReference()) + public Input(Expression expression) : this(expression, new()) { } } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Models/RunWorkflowResult.cs b/src/modules/Elsa.Workflows.Core/Models/RunWorkflowResult.cs index 7cb280819..99d799aeb 100644 --- a/src/modules/Elsa.Workflows.Core/Models/RunWorkflowResult.cs +++ b/src/modules/Elsa.Workflows.Core/Models/RunWorkflowResult.cs @@ -6,9 +6,14 @@ namespace Elsa.Workflows.Models; /// /// Contains information about a workflow run, such as . /// -public record RunWorkflowResult(WorkflowExecutionContext WorkflowExecutionContext, WorkflowState WorkflowState, Workflow Workflow, object? Result); +public record RunWorkflowResult(WorkflowExecutionContext WorkflowExecutionContext, WorkflowState WorkflowState, Workflow Workflow, object? Result, Journal Journal); /// /// Contains information about a workflow run, such as . /// -public record RunWorkflowResult(WorkflowExecutionContext WorkflowExecutionContext, WorkflowState WorkflowState, Workflow Workflow, TResult Result); \ No newline at end of file +public record RunWorkflowResult(WorkflowExecutionContext WorkflowExecutionContext, WorkflowState WorkflowState, Workflow Workflow, TResult Result, Journal Journal); + +public record Journal(ICollection WorkflowExecutionLogEntries, ICollection ActivityExecutionContexts) +{ + public static Journal Empty => new([], []); +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Services/WorkflowRunner.cs b/src/modules/Elsa.Workflows.Core/Services/WorkflowRunner.cs index f426d1aa9..45e9ee0f2 100644 --- a/src/modules/Elsa.Workflows.Core/Services/WorkflowRunner.cs +++ b/src/modules/Elsa.Workflows.Core/Services/WorkflowRunner.cs @@ -45,7 +45,7 @@ public class WorkflowRunner( public async Task> RunAsync(WorkflowBase 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); } /// @@ -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); } } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Runtime.ProtoActor/Actors/WorkflowInstance.cs b/src/modules/Elsa.Workflows.Runtime.ProtoActor/Actors/WorkflowInstance.cs index 938d376ad..4abe5e979 100644 --- a/src/modules/Elsa.Workflows.Runtime.ProtoActor/Actors/WorkflowInstance.cs +++ b/src/modules/Elsa.Workflows.Runtime.ProtoActor/Actors/WorkflowInstance.cs @@ -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; diff --git a/src/modules/Elsa.Workflows.Runtime/Services/WorkflowHost.cs b/src/modules/Elsa.Workflows.Runtime/Services/WorkflowHost.cs index 01e816184..c623263ab 100644 --- a/src/modules/Elsa.Workflows.Runtime/Services/WorkflowHost.cs +++ b/src/modules/Elsa.Workflows.Runtime/Services/WorkflowHost.cs @@ -66,12 +66,12 @@ public class WorkflowHost : IWorkflowHost } /// - public async Task RunWorkflowAsync(RunWorkflowOptions? @params = default, CancellationToken cancellationToken = default) + public async Task 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(); diff --git a/test/integration/Elsa.Activities.IntegrationTests/ForEachTests.cs b/test/integration/Elsa.Activities.IntegrationTests/ForEachTests.cs index 583455911..016c59287 100644 --- a/test/integration/Elsa.Activities.IntegrationTests/ForEachTests.cs +++ b/test/integration/Elsa.Activities.IntegrationTests/ForEachTests.cs @@ -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(expectedLines) { - Body = new WriteLine(context => context.GetVariable("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(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(expectedLines) + { + Body = WriteCurrentValue() + }; + var result = await _serviceProvider.RunActivityAsync(forEach); + var journal = result.Journal; + var forEachContext = journal.ActivityExecutionContexts.FirstOrDefault(x => x.Activity is ForEach); + 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((ICollection)null!) + { + Body = WriteCurrentValue() + }; + var result = await _serviceProvider.RunActivityAsync(forEach); + var journal = result.Journal; + var forEachContext = journal.ActivityExecutionContexts.FirstOrDefault(x => x.Activity is ForEach); + 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(dataSource) + { + Body = new If + { + Condition = new(context => context.GetVariable(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(dataSource) + { + Body = new WriteLine(context => context.GetVariable(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>(() => dataSource.ToList()); + var forEach = new ForEach(dataSourceInput) + { + Body = new Sequence + { + Activities = + [ + new If(context => context.GetVariable(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>(() => dataSource.ToList()); + var forEach = new ForEach(dataSourceInput) + { + Body = new Sequence + { + Activities = + [ + new If(context => context.GetVariable(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>(() => dataSource.ToList()); + var forEach = new ForEach(dataSourceInput) + { + Body = new Sequence + { + Activities = + [ + new If(context => context.GetVariable(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(dataSource) + { + Body = new Sequence + { + Activities = + [ + new If(context => context.GetVariable(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); + 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(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; } \ No newline at end of file