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("Completed"); } } \ No newline at end of file diff --git a/test/integration/Elsa.Activities.IntegrationTests/ForEachTests.cs b/test/integration/Elsa.Activities.IntegrationTests/ForEachTests.cs new file mode 100644 index 000000000..583455911 --- /dev/null +++ b/test/integration/Elsa.Activities.IntegrationTests/ForEachTests.cs @@ -0,0 +1,29 @@ +using Elsa.Extensions; +using Elsa.Testing.Shared; +using Elsa.Workflows.Activities; +using Xunit.Abstractions; + +namespace Elsa.Activities.IntegrationTests; + +public class ForEachTests +{ + private readonly CapturingTextWriter _capturingTextWriter = new(); + private readonly IServiceProvider _serviceProvider; + + 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 forEach = new ForEach(expectedLines) + { + Body = new WriteLine(context => context.GetVariable("CurrentValue")) + }; + await _serviceProvider.RunActivityAsync(forEach); + Assert.Equal(expectedLines, _capturingTextWriter.Lines); + } +} \ No newline at end of file