Add ForEach tests, introduce asynchronous workflow runner and enhance workflow events. (#6926)

* Introduce asynchronous workflow runner and enhance workflow events.

- Added `AsyncWorkflowRunner` to enable asynchronous workflow execution and result tracking.
- Introduced new event arguments, such as `ActivityExecutedEventArgs` and `WorkflowStateCommittedEventArgs`.
- Expanded `WorkflowEvents` class to include `ActivityExecuted`, `ActivityExecutedLogUpdated`, and `WorkflowStateCommitted` events.
- Refactored event arguments into the `Elsa.Testing.Shared.EventArgs` namespace.
- Enhanced tests with `AsyncWorkflowRunner` and new event-driven workflow scenarios.

* Refactor event argument classes to unify namespace and simplify inheritance

* Add shared component DotSettings file to support namespace exclusions
This commit is contained in:
Sipke Schoorstra 2025-09-24 20:06:00 +02:00 committed by GitHub
parent 54c8a010fe
commit bdfbd0886f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 257 additions and 10 deletions

View file

@ -0,0 +1,2 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=eventargs/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>

View file

@ -0,0 +1,8 @@
using Elsa.Workflows;
namespace Elsa.Testing.Shared;
public class ActivityExecutedEventArgs(ActivityExecutionContext activityExecutionContext) : EventArgs
{
public ActivityExecutionContext ActivityExecutionContext { get; } = activityExecutionContext;
}

View file

@ -0,0 +1,10 @@
using Elsa.Workflows;
using Elsa.Workflows.Runtime.Entities;
namespace Elsa.Testing.Shared;
public class ActivityExecutedLogUpdatedEventArgs(WorkflowExecutionContext workflowExecutionContext, ICollection<ActivityExecutionRecord> records) : EventArgs
{
public WorkflowExecutionContext WorkflowExecutionContext { get; } = workflowExecutionContext;
public ICollection<ActivityExecutionRecord> Records { get; } = records;
}

View file

@ -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;
}

View file

@ -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<WorkflowFinished>,
INotificationHandler<WorkflowInstanceSaved>
INotificationHandler<WorkflowInstanceSaved>,
INotificationHandler<WorkflowStateCommitted>,
INotificationHandler<ActivityExecuted>,
INotificationHandler<ActivityExecutionLogUpdated>
{
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;
}
}

View file

@ -4,6 +4,12 @@ public class WorkflowEvents
{
public event EventHandler<WorkflowFinishedEventArgs>? WorkflowFinished;
public event EventHandler<WorkflowInstanceSavedEventArgs>? WorkflowInstanceSaved;
public event EventHandler<WorkflowStateCommittedEventArgs>? WorkflowStateCommitted;
public event EventHandler<ActivityExecutedEventArgs>? ActivityExecuted;
public event EventHandler<ActivityExecutedLogUpdatedEventArgs>? 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);
}

View file

@ -20,7 +20,7 @@ public class LoggingMiddleware : IActivityExecutionMiddleware
{
_next = next;
_logger = logger;
_stopwatch = new Stopwatch();
_stopwatch = new();
}
/// <inheritdoc />

View file

@ -11,7 +11,7 @@ public class WorkflowDefinitionEventConsumer(WorkflowDefinitionEvents workflowDe
{
public Task Consume(ConsumeContext<WorkflowDefinitionDeleted> context)
{
workflowDefinitionEvents.OnWorkflowDefinitionDeleted(new WorkflowDefinitionDeletedEventArgs(context.Message.Id));
workflowDefinitionEvents.OnWorkflowDefinitionDeleted(new(context.Message.Id));
return Task.CompletedTask;
}
}

View file

@ -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);
}
}

View file

@ -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<SignalManager>()
.AddScoped<WorkflowEvents>()
.AddScoped<AsyncWorkflowRunner>()
.AddSingleton<WorkflowEvents>()
.AddScoped<WorkflowDefinitionEvents>()
.AddSingleton<TriggerChangeTokenSignalEvents>()
.AddScoped<IWorkflowMaterializer, TestWorkflowMaterializer>()
.AddNotificationHandlersFrom<WorkflowServer>()
.AddWorkflowDefinitionProvider<TestWorkflowProvider>()
.AddWorkflowsProvider<TestWorkflowProvider>()
.AddNotificationHandlersFrom<WorkflowEventHandlers>()
.Decorate<IChangeTokenSignaler, EventPublishingChangeTokenSignaler>()
;

View file

@ -0,0 +1,10 @@
using Elsa.Workflows.Runtime.Entities;
namespace Elsa.Workflows.ComponentTests.Models;
/// <summary>
/// Represents the result of a test workflow execution, including the workflow execution context and activity execution records.
/// </summary>
/// <param name="WorkflowExecutionContext">The workflow execution context after completion.</param>
/// <param name="ActivityExecutionRecords">The collection of activity execution records for the workflow.</param>
public record TestWorkflowExecutionResult(WorkflowExecutionContext WorkflowExecutionContext, ICollection<ActivityExecutionRecord> ActivityExecutionRecords);

View file

@ -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;
/// <summary>
/// Provides functionality to execute workflows asynchronously and await their completion for testing purposes.
/// Tracks activity execution records and workflow completion signals.
/// </summary>
public class AsyncWorkflowRunner : IDisposable
{
private readonly IWorkflowRuntime _workflowRuntime;
private readonly IIdentityGenerator _identityGenerator;
private readonly SignalManager _signalManager;
private readonly WorkflowEvents _workflowEvents;
private readonly ConcurrentDictionary<string, ActivityExecutionRecord> _activityExecutionRecords = new();
/// <summary>
/// Initializes a new instance of the <see cref="AsyncWorkflowRunner"/> class.
/// </summary>
public AsyncWorkflowRunner(IWorkflowRuntime workflowRuntime, IIdentityGenerator identityGenerator, SignalManager signalManager, WorkflowEvents workflowEvents)
{
_workflowRuntime = workflowRuntime;
_identityGenerator = identityGenerator;
_signalManager = signalManager;
_workflowEvents = workflowEvents;
_workflowEvents.WorkflowStateCommitted += OnWorkflowStateCommitted;
_workflowEvents.ActivityExecutedLogUpdated += OnActivityExecutedLogUpdated;
}
/// <summary>
/// Runs the specified workflow definition asynchronously and waits for its completion.
/// Returns the workflow execution context and activity execution records.
/// </summary>
/// <param name="workflowDefinitionHandle">The handle of the workflow definition to execute.</param>
/// <returns>A <see cref="TestWorkflowExecutionResult"/> containing the workflow execution context and activity execution records.</returns>
public async Task<TestWorkflowExecutionResult> 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<WorkflowExecutionContext>(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}";
/// <summary>
/// Unsubscribes from workflow events and releases resources.
/// </summary>
public void Dispose()
{
_workflowEvents.WorkflowStateCommitted -= OnWorkflowStateCommitted;
_workflowEvents.ActivityExecutedLogUpdated -= OnActivityExecutedLogUpdated;
GC.SuppressFinalize(this);
}
}

View file

@ -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<AsyncWorkflowRunner>();
}
[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);
}
}

View file

@ -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<string>(["a", "b", "c"])
{
Body = new Sequence
{
Activities =
{
new WriteLine(context => $"Processing item: {context.GetVariable<string>("CurrentValue")}"),
Delay.FromMilliseconds(100)
}
}
}
}
};
}
}

View file

@ -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<string>("Completed");
await _signalManager.WaitAsync<object>("Completed");
}
}

View file

@ -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<string>(expectedLines)
{
Body = new WriteLine(context => context.GetVariable<string>("CurrentValue"))
};
await _serviceProvider.RunActivityAsync(forEach);
Assert.Equal(expectedLines, _capturingTextWriter.Lines);
}
}