From bcf19ab23f8e390bad18ec7ee1cdb5f5af24057b Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Sat, 27 Sep 2025 10:33:10 +0200 Subject: [PATCH 1/7] Bump Elsa Studio version to 3.5.1 and update Docker image/tag references. --- .github/workflows/elsa-studio.yml | 2 +- Directory.Packages.props | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/elsa-studio.yml b/.github/workflows/elsa-studio.yml index c050b33e7..9afe71c39 100644 --- a/.github/workflows/elsa-studio.yml +++ b/.github/workflows/elsa-studio.yml @@ -29,7 +29,7 @@ jobs: with: # list of Docker images to use as base name for tags images: | - elsaworkflows/elsa-studio-v3-5-0 + elsaworkflows/elsa-studio-v3-5-1 flavor: | latest=true # generate Docker tags based on the following events/attributes diff --git a/Directory.Packages.props b/Directory.Packages.props index 3255297bf..56417b3be 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,7 +4,7 @@ true - 3.5.1-preview.1211 + 3.5.1-preview.1230 9.0.9 From 7b84bc8237b38e86541662e08c52bfb8ae7eed0a Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Sat, 27 Sep 2025 10:39:23 +0200 Subject: [PATCH 2/7] Add initial `appsettings.json` configuration for logging and Elsa server setup - Introduced logging configuration with default, system, and Microsoft log levels. - Added Elsa server URL and hosting base path settings. --- src/apps/Elsa.Studio.Web/appsettings.json | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 src/apps/Elsa.Studio.Web/appsettings.json diff --git a/src/apps/Elsa.Studio.Web/appsettings.json b/src/apps/Elsa.Studio.Web/appsettings.json new file mode 100644 index 000000000..b617c6e56 --- /dev/null +++ b/src/apps/Elsa.Studio.Web/appsettings.json @@ -0,0 +1,15 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "System": "Information", + "Microsoft": "Information" + } + }, + "ElsaServer": { + "Url": "https://localhost:5001/elsa/api" + }, + "Hosting": { + "BasePath": "" + } +} \ No newline at end of file From 56884a132a8ed8fb5b0298961892e19ada33018b Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Sat, 27 Sep 2025 10:41:20 +0200 Subject: [PATCH 3/7] Simplify GitHub workflow names for consistency and clarity. --- .github/workflows/elsa-server-and-studio.yml | 2 +- .github/workflows/elsa-server.yml | 2 +- .github/workflows/elsa-studio.yml | 2 +- .github/workflows/packages.yml | 2 +- .github/workflows/pr.yml | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/elsa-server-and-studio.yml b/.github/workflows/elsa-server-and-studio.yml index 848d269ef..85c9c4b4e 100644 --- a/.github/workflows/elsa-server-and-studio.yml +++ b/.github/workflows/elsa-server-and-studio.yml @@ -1,4 +1,4 @@ -name: Build and deploy Elsa Server + Studio reference application as Docker image +name: Elsa Server + Studio Docker image on: workflow_dispatch: push: diff --git a/.github/workflows/elsa-server.yml b/.github/workflows/elsa-server.yml index a6f1d8091..a2903217c 100644 --- a/.github/workflows/elsa-server.yml +++ b/.github/workflows/elsa-server.yml @@ -1,4 +1,4 @@ -name: Build and deploy Elsa Server reference application as Docker image +name: Elsa Server Docker image on: workflow_dispatch: push: diff --git a/.github/workflows/elsa-studio.yml b/.github/workflows/elsa-studio.yml index 9afe71c39..baaace7ee 100644 --- a/.github/workflows/elsa-studio.yml +++ b/.github/workflows/elsa-studio.yml @@ -1,4 +1,4 @@ -name: Build and deploy Elsa Studio reference application as Docker image +name: Elsa Studio Docker image on: workflow_dispatch: push: diff --git a/.github/workflows/packages.yml b/.github/workflows/packages.yml index 9458a3d0a..e57c9a589 100644 --- a/.github/workflows/packages.yml +++ b/.github/workflows/packages.yml @@ -1,4 +1,4 @@ -name: Elsa 3 Packages +name: Packages on: workflow_dispatch: push: diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index d99b63e7f..0b038eb07 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -14,7 +14,7 @@ # # ------------------------------------------------------------------------------ -name: pr +name: PR on: pull_request: From b2231b5c202a70afe0ebd6ac58404cd90b64d37a Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Sat, 27 Sep 2025 10:43:17 +0200 Subject: [PATCH 4/7] Update Docker image references in GitHub workflows to use unified versioning scheme (`v3-5`). --- .github/workflows/elsa-server-and-studio.yml | 2 +- .github/workflows/elsa-server.yml | 2 +- .github/workflows/elsa-studio.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/elsa-server-and-studio.yml b/.github/workflows/elsa-server-and-studio.yml index 85c9c4b4e..09873318e 100644 --- a/.github/workflows/elsa-server-and-studio.yml +++ b/.github/workflows/elsa-server-and-studio.yml @@ -29,7 +29,7 @@ jobs: with: # list of Docker images to use as base name for tags images: | - elsaworkflows/elsa-server-and-studio-v3-5-0 + elsaworkflows/elsa-server-and-studio-v3-5 flavor: | latest=true # generate Docker tags based on the following events/attributes diff --git a/.github/workflows/elsa-server.yml b/.github/workflows/elsa-server.yml index a2903217c..3011af454 100644 --- a/.github/workflows/elsa-server.yml +++ b/.github/workflows/elsa-server.yml @@ -29,7 +29,7 @@ jobs: with: # list of Docker images to use as base name for tags images: | - elsaworkflows/elsa-server-v3-5-0 + elsaworkflows/elsa-server-v3-5 flavor: | latest=true # generate Docker tags based on the following events/attributes diff --git a/.github/workflows/elsa-studio.yml b/.github/workflows/elsa-studio.yml index baaace7ee..408631ceb 100644 --- a/.github/workflows/elsa-studio.yml +++ b/.github/workflows/elsa-studio.yml @@ -29,7 +29,7 @@ jobs: with: # list of Docker images to use as base name for tags images: | - elsaworkflows/elsa-studio-v3-5-1 + elsaworkflows/elsa-studio-v3-5 flavor: | latest=true # generate Docker tags based on the following events/attributes From 806bb6564013685344b0e5974c9331f70e76daf4 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Sat, 27 Sep 2025 11:10:52 +0200 Subject: [PATCH 5/7] Bump Elsa Studio version to `3.5.1-preview.1231` and introduce `ElsaExtensionsVersion` property. Adjust package references and update `Elastic.Clients.Elasticsearch` version. --- Directory.Packages.props | 357 ++++++++++++++++++++------------------- 1 file changed, 179 insertions(+), 178 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 56417b3be..1c5174af2 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,180 +1,181 @@ - - true - true - - - 3.5.1-preview.1230 - 9.0.9 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + true + true + + + 3.5.1-preview.1231 + 3.5.1-preview.147 + 9.0.9 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file From bc4e4d60eeb2191ff9e5d8dd00703bf316a85f67 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Sat, 27 Sep 2025 11:42:04 +0200 Subject: [PATCH 6/7] Bump `ElsaExtensionsVersion` to `3.5.1-preview.148`. --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 1c5174af2..afb7bfd09 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -5,7 +5,7 @@ 3.5.1-preview.1231 - 3.5.1-preview.147 + 3.5.1-preview.148 9.0.9 From 003e88c4da608673b591809fe47bc4d34772df25 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Sat, 27 Sep 2025 17:50:19 +0200 Subject: [PATCH 7/7] 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> --- .../DelegateExpressionHandler.cs | 2 +- .../Elsa.Expressions/Models/Literal.cs | 4 +- .../Elsa.Expressions/Models/MemoryRegister.cs | 2 +- .../Services/JintJavaScriptEvaluator.cs | 2 +- .../Contexts/ActivityExecutionContext.cs | 6 - ...cutionContextExtensions.InputEvaluation.cs | 7 +- .../Elsa.Workflows.Core/Models/Argument.cs | 2 +- .../Elsa.Workflows.Core/Models/Input.cs | 16 +- .../Models/RunWorkflowResult.cs | 9 +- .../Services/WorkflowRunner.cs | 7 +- .../Actors/WorkflowInstance.cs | 2 +- .../Services/WorkflowHost.cs | 6 +- .../ForEachTests.cs | 250 +++++++++++++++++- 13 files changed, 280 insertions(+), 35 deletions(-) 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