diff --git a/src/common/Elsa.Testing.Shared.Component/Elsa.Testing.Shared.Component.csproj.DotSettings b/src/common/Elsa.Testing.Shared.Component/Elsa.Testing.Shared.Component.csproj.DotSettings
new file mode 100644
index 000000000..5145fa48d
--- /dev/null
+++ b/src/common/Elsa.Testing.Shared.Component/Elsa.Testing.Shared.Component.csproj.DotSettings
@@ -0,0 +1,2 @@
+
+ True
\ No newline at end of file
diff --git a/src/common/Elsa.Testing.Shared.Component/EventArgs/ActivityExecutedEventArgs.cs b/src/common/Elsa.Testing.Shared.Component/EventArgs/ActivityExecutedEventArgs.cs
new file mode 100644
index 000000000..d31023be6
--- /dev/null
+++ b/src/common/Elsa.Testing.Shared.Component/EventArgs/ActivityExecutedEventArgs.cs
@@ -0,0 +1,8 @@
+using Elsa.Workflows;
+
+namespace Elsa.Testing.Shared;
+
+public class ActivityExecutedEventArgs(ActivityExecutionContext activityExecutionContext) : EventArgs
+{
+ public ActivityExecutionContext ActivityExecutionContext { get; } = activityExecutionContext;
+}
\ No newline at end of file
diff --git a/src/common/Elsa.Testing.Shared.Component/EventArgs/ActivityExecutedLogUpdatedEventArgs.cs b/src/common/Elsa.Testing.Shared.Component/EventArgs/ActivityExecutedLogUpdatedEventArgs.cs
new file mode 100644
index 000000000..b747c608e
--- /dev/null
+++ b/src/common/Elsa.Testing.Shared.Component/EventArgs/ActivityExecutedLogUpdatedEventArgs.cs
@@ -0,0 +1,10 @@
+using Elsa.Workflows;
+using Elsa.Workflows.Runtime.Entities;
+
+namespace Elsa.Testing.Shared;
+
+public class ActivityExecutedLogUpdatedEventArgs(WorkflowExecutionContext workflowExecutionContext, ICollection records) : EventArgs
+{
+ public WorkflowExecutionContext WorkflowExecutionContext { get; } = workflowExecutionContext;
+ public ICollection Records { get; } = records;
+}
\ No newline at end of file
diff --git a/src/common/Elsa.Testing.Shared.Component/EventArgs/WorkflowStateCommittedEventArgs.cs b/src/common/Elsa.Testing.Shared.Component/EventArgs/WorkflowStateCommittedEventArgs.cs
new file mode 100644
index 000000000..4c1c0cf9f
--- /dev/null
+++ b/src/common/Elsa.Testing.Shared.Component/EventArgs/WorkflowStateCommittedEventArgs.cs
@@ -0,0 +1,12 @@
+using Elsa.Workflows;
+using Elsa.Workflows.Management.Entities;
+using Elsa.Workflows.State;
+
+namespace Elsa.Testing.Shared;
+
+public class WorkflowStateCommittedEventArgs(WorkflowExecutionContext workflowExecutionContext, WorkflowState workflowState, WorkflowInstance workflowInstance) : EventArgs
+{
+ public WorkflowExecutionContext WorkflowExecutionContext { get; } = workflowExecutionContext;
+ public WorkflowState WorkflowState { get; } = workflowState;
+ public WorkflowInstance WorkflowInstance { get; } = workflowInstance;
+}
\ No newline at end of file
diff --git a/src/common/Elsa.Testing.Shared.Component/Handlers/WorkflowEventHandlers.cs b/src/common/Elsa.Testing.Shared.Component/Handlers/WorkflowEventHandlers.cs
index abe71cb1d..b2aad6b04 100644
--- a/src/common/Elsa.Testing.Shared.Component/Handlers/WorkflowEventHandlers.cs
+++ b/src/common/Elsa.Testing.Shared.Component/Handlers/WorkflowEventHandlers.cs
@@ -2,6 +2,7 @@ using Elsa.Mediator.Contracts;
using Elsa.Testing.Shared.Services;
using Elsa.Workflows.Management.Notifications;
using Elsa.Workflows.Notifications;
+using Elsa.Workflows.Runtime.Notifications;
using JetBrains.Annotations;
namespace Elsa.Testing.Shared.Handlers;
@@ -9,17 +10,38 @@ namespace Elsa.Testing.Shared.Handlers;
[UsedImplicitly]
public class WorkflowEventHandlers(WorkflowEvents workflowEvents) :
INotificationHandler,
- INotificationHandler
+ INotificationHandler,
+ INotificationHandler,
+ INotificationHandler,
+ INotificationHandler
{
public Task HandleAsync(WorkflowFinished notification, CancellationToken cancellationToken)
{
- workflowEvents.OnWorkflowFinished(new WorkflowFinishedEventArgs(notification.Workflow, notification.WorkflowState));
+ workflowEvents.OnWorkflowFinished(new(notification.Workflow, notification.WorkflowState));
return Task.CompletedTask;
}
public Task HandleAsync(WorkflowInstanceSaved notification, CancellationToken cancellationToken)
{
- workflowEvents.OnWorkflowInstanceSaved(new WorkflowInstanceSavedEventArgs(notification.WorkflowInstance));
+ workflowEvents.OnWorkflowInstanceSaved(new(notification.WorkflowInstance));
+ return Task.CompletedTask;
+ }
+
+ public Task HandleAsync(WorkflowStateCommitted notification, CancellationToken cancellationToken)
+ {
+ workflowEvents.OnWorkflowStateCommitted(new(notification.WorkflowExecutionContext, notification.WorkflowState, notification.WorkflowInstance));
+ return Task.CompletedTask;
+ }
+
+ public Task HandleAsync(ActivityExecuted notification, CancellationToken cancellationToken)
+ {
+ workflowEvents.OnActivityExecuted(new(notification.ActivityExecutionContext));
+ return Task.CompletedTask;
+ }
+
+ public Task HandleAsync(ActivityExecutionLogUpdated notification, CancellationToken cancellationToken)
+ {
+ workflowEvents.OnActivityExecutedLogUpdated(new(notification.WorkflowExecutionContext, notification.Records));
return Task.CompletedTask;
}
}
\ No newline at end of file
diff --git a/src/common/Elsa.Testing.Shared.Component/Services/WorkflowEvents.cs b/src/common/Elsa.Testing.Shared.Component/Services/WorkflowEvents.cs
index a90b9b931..2b31409f0 100644
--- a/src/common/Elsa.Testing.Shared.Component/Services/WorkflowEvents.cs
+++ b/src/common/Elsa.Testing.Shared.Component/Services/WorkflowEvents.cs
@@ -4,6 +4,12 @@ public class WorkflowEvents
{
public event EventHandler? WorkflowFinished;
public event EventHandler? WorkflowInstanceSaved;
+ public event EventHandler? WorkflowStateCommitted;
+ public event EventHandler? ActivityExecuted;
+ public event EventHandler? ActivityExecutedLogUpdated;
public void OnWorkflowFinished(WorkflowFinishedEventArgs args) => WorkflowFinished?.Invoke(this, args);
public void OnWorkflowInstanceSaved(WorkflowInstanceSavedEventArgs args) => WorkflowInstanceSaved?.Invoke(this, args);
+ public void OnWorkflowStateCommitted(WorkflowStateCommittedEventArgs args) => WorkflowStateCommitted?.Invoke(this, args);
+ public void OnActivityExecuted(ActivityExecutedEventArgs args) => ActivityExecuted?.Invoke(this, args);
+ public void OnActivityExecutedLogUpdated(ActivityExecutedLogUpdatedEventArgs args) => ActivityExecutedLogUpdated?.Invoke(this, args);
}
\ No newline at end of file
diff --git a/src/modules/Elsa.Workflows.Core/Middleware/Activities/LoggingMiddleware.cs b/src/modules/Elsa.Workflows.Core/Middleware/Activities/LoggingMiddleware.cs
index de3c8e0d6..d3257f06b 100644
--- a/src/modules/Elsa.Workflows.Core/Middleware/Activities/LoggingMiddleware.cs
+++ b/src/modules/Elsa.Workflows.Core/Middleware/Activities/LoggingMiddleware.cs
@@ -20,7 +20,7 @@ public class LoggingMiddleware : IActivityExecutionMiddleware
{
_next = next;
_logger = logger;
- _stopwatch = new Stopwatch();
+ _stopwatch = new();
}
///
diff --git a/test/component/Elsa.Workflows.ComponentTests/Helpers/Consumers/WorkflowDefinitionEventConsumer.cs b/test/component/Elsa.Workflows.ComponentTests/Helpers/Consumers/WorkflowDefinitionEventConsumer.cs
index 9a637faff..e78aa441a 100644
--- a/test/component/Elsa.Workflows.ComponentTests/Helpers/Consumers/WorkflowDefinitionEventConsumer.cs
+++ b/test/component/Elsa.Workflows.ComponentTests/Helpers/Consumers/WorkflowDefinitionEventConsumer.cs
@@ -11,7 +11,7 @@ public class WorkflowDefinitionEventConsumer(WorkflowDefinitionEvents workflowDe
{
public Task Consume(ConsumeContext context)
{
- workflowDefinitionEvents.OnWorkflowDefinitionDeleted(new WorkflowDefinitionDeletedEventArgs(context.Message.Id));
+ workflowDefinitionEvents.OnWorkflowDefinitionDeleted(new(context.Message.Id));
return Task.CompletedTask;
}
}
\ No newline at end of file
diff --git a/test/component/Elsa.Workflows.ComponentTests/Helpers/Decorators/EventPublishingChangeTokenSignaler.cs b/test/component/Elsa.Workflows.ComponentTests/Helpers/Decorators/EventPublishingChangeTokenSignaler.cs
index daac22ba1..aecc42bc6 100644
--- a/test/component/Elsa.Workflows.ComponentTests/Helpers/Decorators/EventPublishingChangeTokenSignaler.cs
+++ b/test/component/Elsa.Workflows.ComponentTests/Helpers/Decorators/EventPublishingChangeTokenSignaler.cs
@@ -20,7 +20,7 @@ public class EventPublishingChangeTokenSignaler(IChangeTokenSignaler decoratedSe
public ValueTask TriggerTokenAsync(string key, CancellationToken cancellationToken = default)
{
- triggerChangeTokenSignalEvents.RaiseChangeTokenSignalTriggered(new TriggerChangeTokenSignalEventArgs(key));
+ triggerChangeTokenSignalEvents.RaiseChangeTokenSignalTriggered(new(key));
return decoratedService.TriggerTokenAsync(key, cancellationToken);
}
}
\ No newline at end of file
diff --git a/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/WorkflowServer.cs b/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/WorkflowServer.cs
index c9102d9e9..7fee14262 100644
--- a/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/WorkflowServer.cs
+++ b/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/WorkflowServer.cs
@@ -14,6 +14,8 @@ using Elsa.Testing.Shared.Services;
using Elsa.Workflows.ComponentTests.Consumers;
using Elsa.Workflows.ComponentTests.Decorators;
using Elsa.Workflows.ComponentTests.Materializers;
+using Elsa.Workflows.ComponentTests.Scenarios.Activities.ForEach;
+using Elsa.Workflows.ComponentTests.Services;
using Elsa.Workflows.ComponentTests.WorkflowProviders;
using Elsa.Workflows.Management;
using Elsa.Workflows.Runtime.Distributed.Extensions;
@@ -127,12 +129,13 @@ public class WorkflowServer(Infrastructure infrastructure, string url) : WebAppl
{
services
.AddSingleton()
- .AddScoped()
+ .AddScoped()
+ .AddSingleton()
.AddScoped()
.AddSingleton()
.AddScoped()
.AddNotificationHandlersFrom()
- .AddWorkflowDefinitionProvider()
+ .AddWorkflowsProvider()
.AddNotificationHandlersFrom()
.Decorate()
;
diff --git a/test/component/Elsa.Workflows.ComponentTests/Helpers/Models/TestWorkflowExecutionResult.cs b/test/component/Elsa.Workflows.ComponentTests/Helpers/Models/TestWorkflowExecutionResult.cs
new file mode 100644
index 000000000..5db0f9ef8
--- /dev/null
+++ b/test/component/Elsa.Workflows.ComponentTests/Helpers/Models/TestWorkflowExecutionResult.cs
@@ -0,0 +1,10 @@
+using Elsa.Workflows.Runtime.Entities;
+
+namespace Elsa.Workflows.ComponentTests.Models;
+
+///
+/// Represents the result of a test workflow execution, including the workflow execution context and activity execution records.
+///
+/// The workflow execution context after completion.
+/// The collection of activity execution records for the workflow.
+public record TestWorkflowExecutionResult(WorkflowExecutionContext WorkflowExecutionContext, ICollection ActivityExecutionRecords);
\ No newline at end of file
diff --git a/test/component/Elsa.Workflows.ComponentTests/Helpers/Services/AsyncWorkflowRunner.cs b/test/component/Elsa.Workflows.ComponentTests/Helpers/Services/AsyncWorkflowRunner.cs
new file mode 100644
index 000000000..8fb09b3a7
--- /dev/null
+++ b/test/component/Elsa.Workflows.ComponentTests/Helpers/Services/AsyncWorkflowRunner.cs
@@ -0,0 +1,85 @@
+using Elsa.Testing.Shared;
+using Elsa.Testing.Shared.Services;
+using Elsa.Workflows.ComponentTests.Models;
+using Elsa.Workflows.Models;
+using Elsa.Workflows.Runtime;
+using Elsa.Workflows.Runtime.Entities;
+using Elsa.Workflows.Runtime.Messages;
+using System.Collections.Concurrent;
+
+namespace Elsa.Workflows.ComponentTests.Services;
+
+///
+/// Provides functionality to execute workflows asynchronously and await their completion for testing purposes.
+/// Tracks activity execution records and workflow completion signals.
+///
+public class AsyncWorkflowRunner : IDisposable
+{
+ private readonly IWorkflowRuntime _workflowRuntime;
+ private readonly IIdentityGenerator _identityGenerator;
+ private readonly SignalManager _signalManager;
+ private readonly WorkflowEvents _workflowEvents;
+ private readonly ConcurrentDictionary _activityExecutionRecords = new();
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public AsyncWorkflowRunner(IWorkflowRuntime workflowRuntime, IIdentityGenerator identityGenerator, SignalManager signalManager, WorkflowEvents workflowEvents)
+ {
+ _workflowRuntime = workflowRuntime;
+ _identityGenerator = identityGenerator;
+ _signalManager = signalManager;
+ _workflowEvents = workflowEvents;
+
+ _workflowEvents.WorkflowStateCommitted += OnWorkflowStateCommitted;
+ _workflowEvents.ActivityExecutedLogUpdated += OnActivityExecutedLogUpdated;
+ }
+
+ ///
+ /// Runs the specified workflow definition asynchronously and waits for its completion.
+ /// Returns the workflow execution context and activity execution records.
+ ///
+ /// The handle of the workflow definition to execute.
+ /// A containing the workflow execution context and activity execution records.
+ public async Task RunAndAwaitWorkflowCompletionAsync(WorkflowDefinitionHandle workflowDefinitionHandle)
+ {
+ var workflowInstanceId = _identityGenerator.GenerateId();
+ var workflowClient = await _workflowRuntime.CreateClientAsync(workflowInstanceId);
+ await workflowClient.CreateInstanceAsync(new()
+ {
+ WorkflowDefinitionHandle = workflowDefinitionHandle
+ });
+ _activityExecutionRecords.Clear();
+ await workflowClient.RunInstanceAsync(RunWorkflowInstanceRequest.Empty);
+ var signalName = GetSignalName(workflowInstanceId);
+ var workflowExecutionContext = await _signalManager.WaitAsync(signalName);
+ return new(workflowExecutionContext, _activityExecutionRecords.Values.ToList());
+ }
+
+ private void OnWorkflowStateCommitted(object? sender, WorkflowStateCommittedEventArgs e)
+ {
+ if (e.WorkflowExecutionContext.Status != WorkflowStatus.Finished)
+ return;
+
+ var signalName = GetSignalName(e.WorkflowExecutionContext.Id);
+ _signalManager.Trigger(signalName, e.WorkflowExecutionContext);
+ }
+
+ private void OnActivityExecutedLogUpdated(object? sender, ActivityExecutedLogUpdatedEventArgs e)
+ {
+ foreach (var record in e.Records)
+ _activityExecutionRecords[record.Id] = record;
+ }
+
+ private static string GetSignalName(string workflowInstanceId) => $"WorkflowInstanceCompleted-{workflowInstanceId}";
+
+ ///
+ /// Unsubscribes from workflow events and releases resources.
+ ///
+ public void Dispose()
+ {
+ _workflowEvents.WorkflowStateCommitted -= OnWorkflowStateCommitted;
+ _workflowEvents.ActivityExecutedLogUpdated -= OnActivityExecutedLogUpdated;
+ GC.SuppressFinalize(this);
+ }
+}
\ No newline at end of file
diff --git a/test/component/Elsa.Workflows.ComponentTests/Scenarios/Activities/ForEach/ForEachWorkflowTests.cs b/test/component/Elsa.Workflows.ComponentTests/Scenarios/Activities/ForEach/ForEachWorkflowTests.cs
new file mode 100644
index 000000000..3657edbb2
--- /dev/null
+++ b/test/component/Elsa.Workflows.ComponentTests/Scenarios/Activities/ForEach/ForEachWorkflowTests.cs
@@ -0,0 +1,29 @@
+using Elsa.Common.Models;
+using Elsa.Workflows.ComponentTests.Abstractions;
+using Elsa.Workflows.ComponentTests.Fixtures;
+using Elsa.Workflows.ComponentTests.Scenarios.Activities.ForEach.Workflows;
+using Elsa.Workflows.ComponentTests.Services;
+using Elsa.Workflows.Models;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Elsa.Workflows.ComponentTests.Scenarios.Activities.ForEach;
+
+public class ForEachWorkflowTests : AppComponentTest
+{
+ private readonly AsyncWorkflowRunner _workflowRunner;
+
+ public ForEachWorkflowTests(App app) : base(app)
+ {
+ _workflowRunner = Scope.ServiceProvider.GetRequiredService();
+ }
+
+ [Fact(DisplayName = "ForEach activity executes child activity for each collection item and supports blocking activities")]
+ public async Task ForEachActivity_ExecutesChildActivity_ForEachCollectionItem_AndSupportsBlocking()
+ {
+ var result = await _workflowRunner.RunAndAwaitWorkflowCompletionAsync(WorkflowDefinitionHandle.ByDefinitionId(ForEachWorkflow.DefinitionId, VersionOptions.Published));
+ var writeLineExecutionRecords = result.ActivityExecutionRecords.Where(x => x.ActivityId == "WriteLine1").ToList();
+
+ // Assert that the workflow executed the expected number of activities.
+ Assert.Equal(3, writeLineExecutionRecords.Count);
+ }
+}
\ No newline at end of file
diff --git a/test/component/Elsa.Workflows.ComponentTests/Scenarios/Activities/ForEach/Workflows/ForEachWorkflow.cs b/test/component/Elsa.Workflows.ComponentTests/Scenarios/Activities/ForEach/Workflows/ForEachWorkflow.cs
new file mode 100644
index 000000000..803c23d60
--- /dev/null
+++ b/test/component/Elsa.Workflows.ComponentTests/Scenarios/Activities/ForEach/Workflows/ForEachWorkflow.cs
@@ -0,0 +1,31 @@
+using Elsa.Extensions;
+using Elsa.Scheduling.Activities;
+using Elsa.Workflows.Activities;
+
+namespace Elsa.Workflows.ComponentTests.Scenarios.Activities.ForEach.Workflows;
+
+public class ForEachWorkflow : WorkflowBase
+{
+ public static readonly string DefinitionId = Guid.NewGuid().ToString();
+ protected override void Build(IWorkflowBuilder builder)
+ {
+ builder.WithDefinitionId(DefinitionId);
+ builder.Root = new Sequence
+ {
+ Activities =
+ {
+ new ForEach(["a", "b", "c"])
+ {
+ Body = new Sequence
+ {
+ Activities =
+ {
+ new WriteLine(context => $"Processing item: {context.GetVariable("CurrentValue")}"),
+ Delay.FromMilliseconds(100)
+ }
+ }
+ }
+ }
+ };
+ }
+}
\ No newline at end of file
diff --git a/test/component/Elsa.Workflows.ComponentTests/Scenarios/DispatchWorkflows/DispatchWorkflowsTests.cs b/test/component/Elsa.Workflows.ComponentTests/Scenarios/DispatchWorkflows/DispatchWorkflowsTests.cs
index cfb18b6ac..0a4b9eecf 100644
--- a/test/component/Elsa.Workflows.ComponentTests/Scenarios/DispatchWorkflows/DispatchWorkflowsTests.cs
+++ b/test/component/Elsa.Workflows.ComponentTests/Scenarios/DispatchWorkflows/DispatchWorkflowsTests.cs
@@ -26,11 +26,11 @@ public class DispatchWorkflowsTests : AppComponentTest
public async Task DispatchAndWaitWorkflow_ShouldWaitForChildWorkflowToComplete()
{
var workflowClient = await _workflowRuntime.CreateClientAsync();
- await workflowClient.CreateInstanceAsync(new CreateWorkflowInstanceRequest
+ await workflowClient.CreateInstanceAsync(new()
{
WorkflowDefinitionHandle = WorkflowDefinitionHandle.ByDefinitionId(DispatchAndWaitWorkflow.DefinitionId, VersionOptions.Published)
});
await workflowClient.RunInstanceAsync(RunWorkflowInstanceRequest.Empty);
- await _signalManager.WaitAsync("Completed");
+ await _signalManager.WaitAsync